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.
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
- 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 {