From f4df7c3d1916bb0feb81ffd5e65d479bfba2d641 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Sun, 15 Jun 2025 12:15:14 +0530 Subject: [PATCH] Added some basic options --- CLAUDE.md | 121 ++++++ bun.lock | 4 +- package.json | 4 +- src/components/config/ConfigTabs.tsx | 2 +- src/components/config/GiteaConfigForm.tsx | 207 +++++----- .../config/OrganizationStrategy.tsx | 387 ++++++++++++++++++ src/components/ui/badge.tsx | 46 +++ src/components/ui/radio-group.tsx | 43 ++ src/lib/db/schema.ts | 3 +- src/types/config.ts | 2 + 10 files changed, 701 insertions(+), 118 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/config/OrganizationStrategy.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/radio-group.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..960029f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime. + +## Essential Commands + +### Development +```bash +bun run dev # Start development server (port 3000) +bun run build # Build for production +bun run preview # Preview production build +``` + +### Testing +```bash +bun test # Run all tests +bun test:watch # Run tests in watch mode +bun test:coverage # Run tests with coverage +``` + +### Database Management +```bash +bun run init-db # Initialize database +bun run reset-users # Reset user accounts (development) +bun run cleanup-db # Remove database files +``` + +### Production +```bash +bun run start # Start production server +``` + +## Architecture & Key Concepts + +### Technology Stack +- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI +- **Backend**: Bun runtime + SQLite + Drizzle ORM +- **APIs**: GitHub (Octokit) and Gitea APIs +- **Auth**: JWT tokens with bcryptjs password hashing + +### Project Structure +- `/src/pages/api/` - API endpoints (Astro API routes) +- `/src/components/` - React components organized by feature +- `/src/lib/db/` - Database queries and schema (Drizzle ORM) +- `/src/hooks/` - Custom React hooks for data fetching +- `/data/` - SQLite database storage location + +### Key Architectural Patterns + +1. **API Routes**: All API endpoints follow the pattern `/api/[resource]/[action]` and use `createSecureErrorResponse` for consistent error handling: +```typescript +import { createSecureErrorResponse } from '@/lib/utils/error-handler'; + +export async function POST({ request }: APIContext) { + try { + // Implementation + } catch (error) { + return createSecureErrorResponse(error); + } +} +``` + +2. **Database Queries**: Located in `/src/lib/db/queries/` organized by domain (users, repositories, etc.) + +3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates + +4. **Authentication Flow**: + - First user signup creates admin account + - JWT tokens stored in cookies + - Protected routes check auth via `getUserFromCookie()` + +5. **Mirror Process**: + - Discovers repos from GitHub (user/org) + - Creates/updates mirror in Gitea + - Tracks status in database + - Supports scheduled automatic mirroring + +### Database Schema (SQLite) +- `users` - User accounts and authentication +- `configs` - GitHub/Gitea connection settings +- `repositories` - Repository mirror status and metadata +- `organizations` - Organization structure preservation +- `mirror_jobs` - Scheduled mirror operations +- `events` - Activity log and notifications + +### Testing Approach +- Uses Bun's native test runner (`bun:test`) +- Test files use `.test.ts` or `.test.tsx` extension +- Setup file at `/src/tests/setup.bun.ts` +- Mock utilities available for API testing + +### Development Tips +- Environment variables in `.env` (copy from `.env.example`) +- JWT_SECRET auto-generated if not provided +- Database auto-initializes on first run +- Use `bun run dev:clean` for fresh database start +- Tailwind CSS v4 configured with Vite plugin + +### Common Tasks + +**Adding a new API endpoint:** +1. Create file in `/src/pages/api/[resource]/[action].ts` +2. Use `createSecureErrorResponse` for error handling +3. Add corresponding database query in `/src/lib/db/queries/` +4. Update types in `/src/types/` if needed + +**Adding a new component:** +1. Create in appropriate `/src/components/[feature]/` directory +2. Use Shadcn UI components from `/src/components/ui/` +3. Follow existing naming patterns (e.g., `RepositoryCard`, `ConfigTabs`) + +**Modifying database schema:** +1. Update schema in `/src/lib/db/schema.ts` +2. Run `bun run init-db` to recreate database +3. Update related queries in `/src/lib/db/queries/` + + diff --git a/bun.lock b/bun.lock index 7dfb254..918b5c3 100644 --- a/bun.lock +++ b/bun.lock @@ -14,11 +14,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-popover": "^1.1.13", - "@radix-ui/react-radio-group": "^1.3.6", + "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.6", diff --git a/package.json b/package.json index 2a72fc0..7e41176 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-popover": "^1.1.13", - "@radix-ui/react-radio-group": "^1.3.6", + "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.6", diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 251dce5..cacc2d7 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -5,7 +5,6 @@ import { ScheduleConfigForm } from './ScheduleConfigForm'; import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm'; import { MirrorOptionsForm } from './MirrorOptionsForm'; import { AdvancedOptionsForm } from './AdvancedOptionsForm'; -// Removed Tabs import as we're switching to grid layout import type { ConfigApiResponse, GiteaConfig, @@ -679,6 +678,7 @@ export function ConfigTabs() { } onAutoSave={autoSaveGiteaConfig} isAutoSaving={isAutoSavingGitea} + githubUsername={config.githubConfig.username} /> diff --git a/src/components/config/GiteaConfigForm.tsx b/src/components/config/GiteaConfigForm.tsx index a5b0be5..0b79324 100644 --- a/src/components/config/GiteaConfigForm.tsx +++ b/src/components/config/GiteaConfigForm.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -16,20 +16,62 @@ import { } from "../ui/select"; import { Checkbox } from "../ui/checkbox"; import { giteaApi } from "@/lib/api"; -import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config"; +import type { GiteaConfig, GiteaOrgVisibility, MirrorStrategy } from "@/types/config"; import { toast } from "sonner"; import { Info } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { OrganizationStrategy } from "./OrganizationStrategy"; +import { Separator } from "../ui/separator"; interface GiteaConfigFormProps { config: GiteaConfig; setConfig: React.Dispatch>; onAutoSave?: (giteaConfig: GiteaConfig) => Promise; isAutoSaving?: boolean; + githubUsername?: string; } -export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: GiteaConfigFormProps) { +export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) { const [isLoading, setIsLoading] = useState(false); + + // Derive the mirror strategy from existing config for backward compatibility + const getMirrorStrategy = (): MirrorStrategy => { + if (config.mirrorStrategy) return config.mirrorStrategy; + if (config.preserveOrgStructure) return "preserve"; + if (config.organization && config.organization !== config.username) return "single-org"; + return "flat-user"; + }; + + const [mirrorStrategy, setMirrorStrategy] = useState(getMirrorStrategy()); + + // Update config when strategy changes + useEffect(() => { + const newConfig = { ...config }; + + switch (mirrorStrategy) { + case "preserve": + newConfig.preserveOrgStructure = true; + newConfig.mirrorStrategy = "preserve"; + break; + case "single-org": + newConfig.preserveOrgStructure = false; + newConfig.mirrorStrategy = "single-org"; + if (!newConfig.organization) { + newConfig.organization = "github-mirrors"; + } + break; + case "flat-user": + newConfig.preserveOrgStructure = false; + newConfig.mirrorStrategy = "flat-user"; + newConfig.organization = ""; + break; + } + + setConfig(newConfig); + if (onAutoSave) { + onAutoSave(newConfig); + } + }, [mirrorStrategy]); const handleChange = ( e: React.ChangeEvent @@ -168,124 +210,65 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }:

+ + + { + const newConfig = { ...config, organization: org }; + setConfig(newConfig); + if (onAutoSave) onAutoSave(newConfig); + }} + onStarredReposOrgChange={(org) => { + const newConfig = { ...config, starredReposOrg: org }; + setConfig(newConfig); + if (onAutoSave) onAutoSave(newConfig); + }} + githubUsername={githubUsername} + giteaUsername={config.username} + /> + + +
- -

- Repos are created here if no per-repo org is set. -

-
- -
- + - handleChange({ - target: { name: "visibility", value }, - } as React.ChangeEvent) - } - > - - - - - {(["public", "private", "limited"] as GiteaOrgVisibility[]).map( - (option) => ( - - {option.charAt(0).toUpperCase() + option.slice(1)} - - ) - )} - - -
- -
- - -

- Leave blank to use 'github'. -

-
+ + + + + {(["public", "private", "limited"] as GiteaOrgVisibility[]).map( + (option) => ( + + {option.charAt(0).toUpperCase() + option.slice(1)} + + ) + )} + + +

+ Visibility for newly created organizations +

diff --git a/src/components/config/OrganizationStrategy.tsx b/src/components/config/OrganizationStrategy.tsx new file mode 100644 index 0000000..d5c3804 --- /dev/null +++ b/src/components/config/OrganizationStrategy.tsx @@ -0,0 +1,387 @@ +import React from "react"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Badge } from "@/components/ui/badge"; +import { Info, GitBranch, FolderTree, Package, Star, Building2, User } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type MirrorStrategy = "preserve" | "single-org" | "flat-user"; + +interface OrganizationStrategyProps { + strategy: MirrorStrategy; + destinationOrg?: string; + starredReposOrg?: string; + onStrategyChange: (strategy: MirrorStrategy) => void; + onDestinationOrgChange: (org: string) => void; + onStarredReposOrgChange: (org: string) => void; + githubUsername?: string; + giteaUsername?: string; +} + +const strategyConfig = { + preserve: { + title: "Mirror GitHub Structure", + icon: FolderTree, + description: "Keep the same organization structure as GitHub", + color: "text-blue-600", + bgColor: "bg-blue-50", + borderColor: "border-blue-200", + details: [ + "Personal repos → Your Gitea username", + "Org repos → Same org name in Gitea", + "Team structure preserved" + ] + }, + "single-org": { + title: "Consolidate to One Org", + icon: Building2, + description: "Mirror all repositories into a single organization", + color: "text-purple-600", + bgColor: "bg-purple-50", + borderColor: "border-purple-200", + details: [ + "All repos in one place", + "Simplified management", + "Custom organization name" + ] + }, + "flat-user": { + title: "Flat User Structure", + icon: User, + description: "Mirror all repositories under your user account", + color: "text-green-600", + bgColor: "bg-green-50", + borderColor: "border-green-200", + details: [ + "All repos under your username", + "No organizations needed", + "Simple and personal" + ] + } +}; + +const StrategyVisualizer: React.FC<{ + strategy: MirrorStrategy; + destinationOrg?: string; + starredReposOrg?: string; + githubUsername?: string; + giteaUsername?: string; +}> = ({ strategy, destinationOrg, starredReposOrg, githubUsername = "you", giteaUsername = "you" }) => { + const renderPreserveStructure = () => ( +
+
+
GitHub
+
+
+ + {githubUsername}/my-repo +
+
+ + my-org/team-repo +
+
+ + awesome/starred-repo +
+
+
+ +
+ +
+ +
+
Gitea
+
+
+ + {giteaUsername}/my-repo +
+
+ + my-org/team-repo +
+
+ + {starredReposOrg || "starred"}/starred-repo +
+
+
+
+ ); + + const renderSingleOrg = () => ( +
+
+
GitHub
+
+
+ + {githubUsername}/my-repo +
+
+ + my-org/team-repo +
+
+ + awesome/starred-repo +
+
+
+ +
+ +
+ +
+
Gitea
+
+
+ + {destinationOrg || "github-mirrors"}/my-repo +
+
+ + {destinationOrg || "github-mirrors"}/team-repo +
+
+ + {starredReposOrg || "starred"}/starred-repo +
+
+
+
+ ); + + const renderFlatUser = () => ( +
+
+
GitHub
+
+
+ + {githubUsername}/my-repo +
+
+ + my-org/team-repo +
+
+ + awesome/starred-repo +
+
+
+ +
+ +
+ +
+
Gitea
+
+
+ + {giteaUsername}/my-repo +
+
+ + {giteaUsername}/team-repo +
+
+ + {starredReposOrg || "starred"}/starred-repo +
+
+
+
+ ); + + return ( +
+ +
+

+ + Repository Mapping Preview +

+
+ {strategy === "preserve" && renderPreserveStructure()} + {strategy === "single-org" && renderSingleOrg()} + {strategy === "flat-user" && renderFlatUser()} +
+
+ ); +}; + +export const OrganizationStrategy: React.FC = ({ + strategy, + destinationOrg, + starredReposOrg, + onStrategyChange, + onDestinationOrgChange, + onStarredReposOrgChange, + githubUsername, + giteaUsername, +}) => { + + return ( +
+
+

Organization Strategy

+

+ Choose how your repositories will be organized in Gitea +

+
+ + +
+ {(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => { + const isSelected = strategy === key; + const Icon = config.icon; + + return ( +
+
+ + + {strategy === "single-org" && ( +
+ +
+
+ + onDestinationOrgChange(e.target.value)} + placeholder="github-mirrors" + className="mt-1.5" + /> +
+
+
+
+ )} + + +
+
+ + onStarredReposOrgChange(e.target.value)} + placeholder="starred" + className="mt-1.5" + /> +

+ Keep starred repos organized separately from your own repositories +

+
+
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..dc682eb --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index f6189ee..45887d0 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -34,7 +34,6 @@ export const configSchema = z.object({ excludeOrgs: z.array(z.string()).default([]), mirrorPublicOrgs: z.boolean().default(false), publicOrgs: z.array(z.string()).default([]), - preserveOrgStructure: z.boolean().default(false), skipStarredIssues: z.boolean().default(false), }), giteaConfig: z.object({ @@ -44,6 +43,8 @@ export const configSchema = z.object({ organization: z.string().optional(), visibility: z.enum(["public", "private", "limited"]).default("public"), starredReposOrg: z.string().default("github"), + preserveOrgStructure: z.boolean().default(false), + mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).optional(), }), include: z.array(z.string()).default(["*"]), exclude: z.array(z.string()).default([]), diff --git a/src/types/config.ts b/src/types/config.ts index dbdd1b1..01fbba1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,7 @@ import { type Config as ConfigType } from "@/lib/db/schema"; export type GiteaOrgVisibility = "public" | "private" | "limited"; +export type MirrorStrategy = "preserve" | "single-org" | "flat-user"; export interface GiteaConfig { url: string; @@ -10,6 +11,7 @@ export interface GiteaConfig { visibility: GiteaOrgVisibility; starredReposOrg: string; preserveOrgStructure: boolean; + mirrorStrategy?: MirrorStrategy; // New field for the strategy } export interface ScheduleConfig {