diff --git a/docs/superpowers/plans/2026-03-31-gitm-implementation.md b/docs/superpowers/plans/2026-03-31-gitm-implementation.md new file mode 100644 index 0000000..6a67e96 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-gitm-implementation.md @@ -0,0 +1,4568 @@ +# GitM Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a cross-platform Gitea repository sync tool with a single-binary Go backend and embedded Vue 3 frontend + +**Architecture:** Go (Gin) backend with SQLite storage, Vue 3 + Element Plus frontend embedded via Go embed, JWT authentication, Gitea API integration for repo discovery and git-based mirroring + +**Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt + +--- + +## Phase 1: Project Initialization and Core Infrastructure + +### Task 1: Initialize Go Module and Project Structure + +**Files:** +- Create: `go.mod` +- Create: `go.sum` (auto-generated) +- Create: `Makefile` +- Create: `main.go` +- Create: `README.md` + +- [ ] **Step 1: Initialize Go module** + +```bash +cd /c/electron/gitm +go mod init gitm +``` + +- [ ] **Step 2: Create go.mod with dependencies** + +Create `go.mod`: + +```go +module gitm + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.25.0 + github.com/robfig/cron/v3 v3.0.1 +) +``` + +- [ ] **Step 3: Download dependencies** + +```bash +go mod tidy +``` + +Expected: `go.sum` created, dependencies downloaded + +- [ ] **Step 4: Create Makefile** + +Create `Makefile`: + +```makefile +.PHONY: all build frontend clean test run + +# Build frontend first, then build the binary +all: frontend build + +# Build Vue frontend +frontend: + cd web && npm install && npm run build + +# Build Go binary +build: + go build -o bin/gitm . + +# Clean build artifacts +clean: + rm -rf bin/ + rm -rf web/dist/ + rm -rf web/node_modules/ + +# Run tests +test: + go test -v ./... + +# Run the application +run: + go run main.go + +# Cross-compile for Linux +build-linux: + GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux . + +# Cross-compile for Windows +build-windows: + GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe . +``` + +- [ ] **Step 5: Create main.go skeleton** + +Create `main.go`: + +```go +package main + +import ( + "flag" + "fmt" + "log" +) + +var ( + flagAddr = flag.String("addr", ":9000", "Listen address") + flagDataDir = flag.String("data", "./data", "Data directory") + flagInit = flag.Bool("init", false, "Initialize database and set password") +) + +func main() { + flag.Parse() + + fmt.Printf("GitM - Gitea Repository Sync Tool\n") + fmt.Printf("Listen: %s\n", *flagAddr) + fmt.Printf("Data: %s\n", *flagDataDir) + + if *flagInit { + fmt.Println("Initialize mode: TODO") + return + } + + log.Fatal(runServer()) +} + +func runServer() error { + // TODO: implement server + return fmt.Errorf("not implemented") +} +``` + +- [ ] **Step 6: Create README.md** + +Create `README.md`: + +```markdown +# GitM - Gitea Repository Sync Tool + +Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage. + +## Features + +- Single binary deployment +- Web UI for management (Vue 3 + Element Plus) +- SQLite database +- JWT authentication +- Scheduled and manual sync +- Cross-platform (Windows, Linux) + +## Build + +```bash +make all +``` + +## Run + +```bash +# First run - initialize +./bin/gitm --init + +# Normal run +./bin/gitm + +# Custom port +./bin/gitm --addr :9090 +``` + +## Development + +```bash +# Install frontend deps +cd web && npm install + +# Run frontend dev server +cd web && npm run dev + +# Run backend (requires frontend built first) +go run main.go +``` +``` + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum Makefile main.go README.md +git commit -m "feat: initialize project structure and dependencies" +``` + +--- + +### Task 2: Create Internal Package Structure + +**Files:** +- Create: `internal/config/config.go` +- Create: `internal/models/models.go` + +- [ ] **Step 1: Create config package** + +Create `internal/config/config.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "sync" +) + +type Config struct { + ListenAddr string + DataDir string + DBPath string + ReposDir string + once sync.Once +} + +var ( + instance *Config + initOnce sync.Once +) + +func Get() *Config { + initOnce.Do(func() { + instance = &Config{ + ListenAddr: ":9000", + DataDir: "./data", + } + }) + return instance +} + +func (c *Config) SetDataDir(dir string) { + c.DataDir = dir + c.DBPath = filepath.Join(dir, "gitm.db") + c.ReposDir = filepath.Join(dir, "repos") +} + +func (c *Config) EnsureDirs() error { + if err := os.MkdirAll(c.DataDir, 0755); err != nil { + return err + } + return os.MkdirAll(c.ReposDir, 0755) +} +``` + +- [ ] **Step 2: Create models package** + +Create `internal/models/models.go`: + +```go +package models + +import "time" + +// GiteaServer represents a Gitea server configuration +type GiteaServer struct { + ID int64 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + URL string `json:"url" db:"url"` + Token string `json:"-" db:"token"` // Never expose token in JSON + SyncInterval int `json:"sync_interval" db:"sync_interval"` // 0 = manual only + LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"` + Status string `json:"status" db:"status"` // active, disabled + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Repo represents a discovered repository +type Repo struct { + ID int64 `json:"id" db:"id"` + ServerID int64 `json:"server_id" db:"server_id"` + Name string `json:"name" db:"name"` + FullName string `json:"full_name" db:"full_name"` + CloneURL string `json:"clone_url" db:"clone_url"` + LocalPath string `json:"local_path" db:"local_path"` + Size int64 `json:"size" db:"size"` + LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"` + SyncStatus string `json:"sync_status" db:"sync_status"` // syncing, success, failed, pending + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SyncLog represents a sync operation log +type SyncLog struct { + ID int64 `json:"id" db:"id"` + ServerID int64 `json:"server_id" db:"server_id"` + RepoID *int64 `json:"repo_id" db:"repo_id"` + Status string `json:"status" db:"status"` + Message string `json:"message" db:"message"` + StartedAt time.Time `json:"started_at" db:"started_at"` + FinishedAt *time.Time `json:"finished_at" db:"finished_at"` +} + +// Setting represents a key-value configuration +type Setting struct { + Key string `json:"key" db:"key"` + Value string `json:"value" db:"value"` +} + +// SyncStatus represents the current sync state +type SyncStatus struct { + ServerID int64 `json:"server_id"` + Status string `json:"status"` // idle, syncing, failed + TaskID string `json:"task_id,omitempty"` + StartedAt string `json:"started_at,omitempty"` +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/config/config.go internal/models/models.go +git commit -m "feat: add config and models packages" +``` + +--- + +### Task 3: Implement Database Layer + +**Files:** +- Create: `internal/database/database.go` + +- [ ] **Step 1: Create database package** + +Create `internal/database/database.go`: + +```go +package database + +import ( + "database/sql" + "fmt" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" + "gitm/internal/config" + "gitm/internal/models" +) + +var DB *sql.DB + +// Initialize opens the database connection and creates tables +func Initialize(dbPath string) error { + var err error + DB, err = sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + if err = DB.Ping(); err != nil { + return fmt.Errorf("failed to ping database: %w", err) + } + + if err = createTables(); err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + return nil +} + +func createTables() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS gitea_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + token TEXT NOT NULL, + sync_interval INTEGER DEFAULT 0, + last_sync_at DATETIME, + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + name TEXT, + full_name TEXT, + clone_url TEXT, + local_path TEXT, + size INTEGER DEFAULT 0, + last_sync_at DATETIME, + sync_status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS sync_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + repo_id INTEGER, + status TEXT NOT NULL, + message TEXT, + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE, + FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE SET NULL + )`, + `CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )`, + } + + for _, q := range queries { + if _, err := DB.Exec(q); err != nil { + return fmt.Errorf("failed to create table: %w", err) + } + } + + return nil +} + +// Close closes the database connection +func Close() error { + if DB != nil { + return DB.Close() + } + return nil +} + +// Settings operations +func GetSetting(key string) (string, error) { + var value string + err := DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, err +} + +func SetSetting(key, value string) error { + _, err := DB.Exec(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `, key, value) + return err +} + +// Server operations +func CreateServer(server *models.GiteaServer) error { + result, err := DB.Exec(` + INSERT INTO gitea_servers (name, url, token, sync_interval, status) + VALUES (?, ?, ?, ?, ?) + `, server.Name, server.URL, server.Token, server.SyncInterval, server.Status) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + server.ID = id + return nil +} + +func GetServers() ([]models.GiteaServer, error) { + rows, err := DB.Query("SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at FROM gitea_servers ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + + var servers []models.GiteaServer + for rows.Next() { + var s models.GiteaServer + err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt) + if err != nil { + return nil, err + } + servers = append(servers, s) + } + return servers, nil +} + +func GetServer(id int64) (*models.GiteaServer, error) { + var s models.GiteaServer + err := DB.QueryRow(` + SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at + FROM gitea_servers WHERE id = ? + `, id).Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &s, err +} + +func UpdateServer(server *models.GiteaServer) error { + _, err := DB.Exec(` + UPDATE gitea_servers + SET name = ?, url = ?, token = ?, sync_interval = ?, status = ? + WHERE id = ? + `, server.Name, server.URL, server.Token, server.SyncInterval, server.Status, server.ID) + return err +} + +func DeleteServer(id int64) error { + _, err := DB.Exec("DELETE FROM gitea_servers WHERE id = ?", id) + return err +} + +func UpdateServerLastSync(id int64, t time.Time) error { + _, err := DB.Exec("UPDATE gitea_servers SET last_sync_at = ? WHERE id = ?", t, id) + return err +} + +// Repo operations +func CreateOrUpdateRepo(repo *models.Repo) error { + result, err := DB.Exec(` + INSERT INTO repos (server_id, name, full_name, clone_url, local_path, sync_status) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + `, repo.ServerID, repo.Name, repo.FullName, repo.CloneURL, repo.LocalPath, repo.SyncStatus) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + repo.ID = id + return nil +} + +func GetReposByServer(serverID int64) ([]models.Repo, error) { + rows, err := DB.Query(` + SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at + FROM repos WHERE server_id = ? ORDER BY full_name + `, serverID) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []models.Repo + for rows.Next() { + var r models.Repo + err := rows.Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt) + if err != nil { + return nil, err + } + repos = append(repos, r) + } + return repos, nil +} + +func UpdateRepoSyncStatus(id int64, status string) error { + _, err := DB.Exec("UPDATE repos SET sync_status = ?, last_sync_at = CURRENT_TIMESTAMP WHERE id = ?", status, id) + return err +} + +func GetRepoByFullName(serverID int64, fullName string) (*models.Repo, error) { + var r models.Repo + err := DB.QueryRow(` + SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at + FROM repos WHERE server_id = ? AND full_name = ? + `, serverID, fullName).Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &r, err +} + +// Sync log operations +func CreateSyncLog(log *models.SyncLog) error { + result, err := DB.Exec(` + INSERT INTO sync_logs (server_id, repo_id, status, message, started_at) + VALUES (?, ?, ?, ?, ?) + `, log.ServerID, log.RepoID, log.Status, log.Message, log.StartedAt) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + log.ID = id + return nil +} + +func UpdateSyncLog(id int64, status, message string, finishedAt *time.Time) error { + _, err := DB.Exec(` + UPDATE sync_logs SET status = ?, message = ?, finished_at = ? WHERE id = ? + `, status, message, finishedAt, id) + return err +} + +func GetSyncLogs(serverID int64, limit, offset int) ([]models.SyncLog, error) { + query := ` + SELECT id, server_id, repo_id, status, message, started_at, finished_at + FROM sync_logs + ` + args := []interface{}{} + + if serverID > 0 { + query += " WHERE server_id = ?" + args = append(args, serverID) + } + + query += " ORDER BY started_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var logs []models.SyncLog + for rows.Next() { + var l models.SyncLog + err := rows.Scan(&l.ID, &l.ServerID, &l.RepoID, &l.Status, &l.Message, &l.StartedAt, &l.FinishedAt) + if err != nil { + return nil, err + } + logs = append(logs, l) + } + return logs, nil +} +``` + +- [ ] **Step 2: Add missing import** + +Edit `internal/database/database.go`, add `"time"` to imports: + +```go +import ( + "database/sql" + "fmt" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" + "gitm/internal/config" + "gitm/internal/models" +) +``` + +- [ ] **Step 3: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success, binary created + +- [ ] **Step 4: Commit** + +```bash +git add internal/database/ +git commit -m "feat: implement SQLite database layer" +``` + +--- + +### Task 4: Implement JWT Authentication + +**Files:** +- Create: `internal/middleware/auth.go` +- Create: `internal/handler/auth.go` + +- [ ] **Step 1: Create auth middleware** + +Create `internal/middleware/auth.go`: + +```go +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +var jwtSecret = []byte("your-secret-key-change-this") + +func SetJWTSecret(secret string) { + jwtSecret = []byte(secret) +} + +// GenerateToken creates a new JWT token +func GenerateToken(userID string) (string, error) { + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// ValidateToken parses and validates a JWT token +func ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, jwt.ErrSignatureInvalid +} + +// AuthMiddleware validates JWT tokens +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"}) + c.Abort() + return + } + + claims, err := ValidateToken(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Next() + } +} +``` + +- [ ] **Step 2: Add missing import** + +Edit `internal/middleware/auth.go`, add `"time"` to imports: + +```go +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) +``` + +- [ ] **Step 3: Create auth handler** + +Create `internal/handler/auth.go`: + +```go +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gitm/internal/database" + "gitm/internal/middleware" +) + +type LoginRequest struct { + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` +} + +type SettingsResponse struct { + AdminPassword string `json:"admin_password,omitempty"` + ListenAddr string `json:"listen_addr"` + ReposDir string `json:"repos_dir"` + MaxConcurrent int `json:"max_concurrent"` +} + +type UpdateSettingsRequest struct { + AdminPassword string `json:"admin_password,omitempty"` + ListenAddr string `json:"listen_addr,omitempty"` + ReposDir string `json:"repos_dir,omitempty"` + MaxConcurrent *int `json:"max_concurrent,omitempty"` +} + +// HandleLogin handles password authentication +func HandleLogin(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"}) + return + } + + // Get stored password hash + hashedPassword, err := database.GetSetting("admin_password") + if err != nil || hashedPassword == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Server not initialized"}) + return + } + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) + return + } + + // Generate token + token, err := middleware.GenerateToken("admin") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + c.JSON(http.StatusOK, LoginResponse{Token: token}) +} + +// HandleGetSettings returns current settings (with password masked) +func HandleGetSettings(c *gin.Context) { + listenAddr, _ := database.GetSetting("listen_addr") + if listenAddr == "" { + listenAddr = ":9000" + } + + reposDir, _ := database.GetSetting("repos_dir") + maxConcurrent, _ := database.GetSetting("max_concurrent") + + maxCon := 3 + if maxConcurrent != "" { + fmt.Sscanf(maxConcurrent, "%d", &maxCon) + } + + c.JSON(http.StatusOK, SettingsResponse{ + AdminPassword: "********", // Masked + ListenAddr: listenAddr, + ReposDir: reposDir, + MaxConcurrent: maxCon, + }) +} + +// HandleUpdateSettings updates server settings +func HandleUpdateSettings(c *gin.Context) { + var req UpdateSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update password if provided + if req.AdminPassword != "" && req.AdminPassword != "********" { + hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + if err := database.SetSetting("admin_password", string(hash)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"}) + return + } + } + + // Update other settings + if req.ListenAddr != "" { + database.SetSetting("listen_addr", req.ListenAddr) + } + if req.ReposDir != "" { + database.SetSetting("repos_dir", req.ReposDir) + } + if req.MaxConcurrent != nil { + database.SetSetting("max_concurrent", fmt.Sprintf("%d", *req.MaxConcurrent)) + } + + c.JSON(http.StatusOK, gin.H{"message": "Settings updated"}) +} + +// HandleInit initializes the admin password +func HandleInit(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"}) + return + } + + // Check if already initialized + hashedPassword, _ := database.GetSetting("admin_password") + if hashedPassword != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Already initialized"}) + return + } + + // Hash and store password + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + if err := database.SetSetting("admin_password", string(hash)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"}) + return + } + + // Set default values + database.SetSetting("listen_addr", ":9000") + database.SetSetting("max_concurrent", "3") + + c.JSON(http.StatusOK, gin.H{"message": "Initialized successfully"}) +} +``` + +- [ ] **Step 4: Add missing imports** + +Edit `internal/handler/auth.go`, add imports: + +```go +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gitm/internal/database" + "gitm/internal/middleware" +) +``` + +- [ ] **Step 5: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 6: Commit** + +```bash +git add internal/middleware/ internal/handler/ +git commit -m "feat: implement JWT authentication middleware and handlers" +``` + +--- + +## Phase 2: Gitea API Integration + +### Task 5: Implement Gitea API Client + +**Files:** +- Create: `internal/gitea/client.go` +- Create: `internal/gitea/types.go` + +- [ ] **Step 1: Create Gitea types** + +Create `internal/gitea/types.go`: + +```go +package gitea + +// GiteaUser represents a Gitea user +type GiteaUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` +} + +// GiteaRepo represents a Gitea repository +type GiteaRepo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + SSHURL string `json:"ssh_url"` + Private bool `json:"private"` + Size int64 `json:"size"` + Updated string `json:"updated_at"` + Owner *GiteaUser `json:"owner"` +} + +// GiteaSearchResponse represents the search API response +type GiteaSearchResponse struct { + OK bool `json:"ok"` + Data []GiteaRepo `json:"data"` +} +``` + +- [ ] **Step 2: Create Gitea client** + +Create `internal/gitea/client.go`: + +```go +package gitea + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client represents a Gitea API client +type Client struct { + baseURL *url.URL + httpClient *http.Client + token string +} + +// NewClient creates a new Gitea API client +func NewClient(serverURL, token string) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid server URL: %w", err) + } + + return &Client{ + baseURL: u, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + token: token, + }, nil +} + +// do performs an HTTP request with authentication +func (c *Client) do(path string) (*http.Response, error) { + // Build URL with token as query parameter + apiURL := c.baseURL.Join(path) + apiURL.RawQuery = url.Values{"token": {c.token}}.Encode() + + resp, err := c.httpClient.Get(apiURL.String()) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + return resp, nil +} + +// ValidateToken checks if the current token is valid +func (c *Client) ValidateToken() (*GiteaUser, error) { + resp, err := c.do("/api/v1/user") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("authentication failed: %s - %s", resp.Status, string(body)) + } + + var user GiteaUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} + +// SearchRepos searches for repositories, paginated +func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) { + resp, err := c.do(fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("search failed: %s - %s", resp.Status, string(body)) + } + + var searchResp GiteaSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return searchResp.Data, nil +} + +// GetAllRepos fetches all repositories by paginating through results +func (c *Client) GetAllRepos() ([]GiteaRepo, error) { + var allRepos []GiteaRepo + page := 1 + limit := 50 + + for { + repos, err := c.SearchRepos(page, limit) + if err != nil { + return nil, err + } + + if len(repos) == 0 { + break + } + + allRepos = append(allRepos, repos...) + + if len(repos) < limit { + break + } + + page++ + } + + return allRepos, nil +} +``` + +- [ ] **Step 3: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 4: Commit** + +```bash +git add internal/gitea/ +git commit -m "feat: implement Gitea API client" +``` + +--- + +### Task 6: Implement Sync Engine + +**Files:** +- Create: `internal/sync/engine.go` +- Create: `internal/sync/scheduler.go` + +- [ ] **Step 1: Create sync engine** + +Create `internal/sync/engine.go`: + +```go +package sync + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "gitm/internal/database" + "gitm/internal/gitea" +) + +// Engine handles repository synchronization +type Engine struct { + maxConcurrent int + mu sync.Mutex + activeTasks map[int64]string // serverID -> taskID +} + +// NewEngine creates a new sync engine +func NewEngine(maxConcurrent int) *Engine { + return &Engine{ + maxConcurrent: maxConcurrent, + activeTasks: make(map[int64]string), + } +} + +// SyncServer syncs all repositories from a Gitea server +func (e *Engine) SyncServer(serverID int64) error { + e.mu.Lock() + if _, active := e.activeTasks[serverID]; active { + e.mu.Unlock() + return fmt.Errorf("sync already in progress for server %d", serverID) + } + taskID := fmt.Sprintf("%d-%d", serverID, time.Now().Unix()) + e.activeTasks[serverID] = taskID + e.mu.Unlock() + + defer func() { + e.mu.Lock() + delete(e.activeTasks, serverID) + e.mu.Unlock() + }() + + // Get server config + server, err := database.GetServer(serverID) + if err != nil { + return fmt.Errorf("failed to get server: %w", err) + } + + // Create sync log + log := &database.SyncLog{ + ServerID: serverID, + Status: "in_progress", + Message: "Starting sync", + StartedAt: time.Now(), + } + if err := database.CreateSyncLog(log); err != nil { + return fmt.Errorf("failed to create sync log: %w", err) + } + + // Create Gitea client + client, err := gitea.NewClient(server.URL, server.Token) + if err != nil { + e.finishLog(log, "failed", err.Error()) + return fmt.Errorf("failed to create client: %w", err) + } + + // Validate token + if _, err := client.ValidateToken(); err != nil { + e.finishLog(log, "failed", fmt.Sprintf("Authentication failed: %v", err)) + return fmt.Errorf("token validation failed: %w", err) + } + + // Get all repos + giteaRepos, err := client.GetAllRepos() + if err != nil { + e.finishLog(log, "failed", fmt.Sprintf("Failed to fetch repos: %v", err)) + return fmt.Errorf("failed to get repos: %w", err) + } + + // Get repos directory + reposDir, _ := database.GetSetting("repos_dir") + if reposDir == "" { + reposDir = "./data/repos" + } + serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name)) + if err := os.MkdirAll(serverDir, 0755); err != nil { + e.finishLog(log, "failed", fmt.Sprintf("Failed to create directory: %v", err)) + return fmt.Errorf("failed to create server directory: %w", err) + } + + // Sync each repo + successCount := 0 + failedCount := 0 + sem := make(chan struct{}, e.maxConcurrent) + var wg sync.WaitGroup + var resultsMu sync.Mutex + + for _, gr := range giteaRepos { + wg.Add(1) + go func(giteaRepo gitea.GiteaRepo) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + // Check if repo exists + existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName) + + // Determine local path + ownerName := giteaRepo.FullName + if giteaRepo.Owner != nil { + ownerName = filepath.Join(giteaRepo.Owner.Login, giteaRepo.Name) + } + localPath := filepath.Join(serverDir, giteaRepo.FullName+".git") + + // Clone or fetch + var err error + if existingRepo == nil { + err = e.cloneMirror(giteaRepo.CloneURL, localPath) + if err == nil { + // Create repo record + repo := &database.Repo{ + ServerID: serverID, + Name: giteaRepo.Name, + FullName: giteaRepo.FullName, + CloneURL: giteaRepo.CloneURL, + LocalPath: localPath, + SyncStatus: "success", + } + database.CreateOrUpdateRepo(repo) + } + } else { + err = e.fetchMirror(localPath) + if err == nil { + database.UpdateRepoSyncStatus(existingRepo.ID, "success") + } + } + + resultsMu.Lock() + if err != nil { + failedCount++ + // Create failure log + failLog := &database.SyncLog{ + ServerID: serverID, + RepoID: getRepoID(serverID, giteaRepo.FullName), + Status: "failed", + Message: err.Error(), + StartedAt: time.Now(), + } + db.CreateSyncLog(failLog) + } else { + successCount++ + } + resultsMu.Unlock() + }(gr) + } + + wg.Wait() + + // Update server last sync + now := time.Now() + database.UpdateServerLastSync(serverID, now) + + // Finish log + message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount) + e.finishLog(log, "success", message) + + return nil +} + +// cloneMirror performs a git clone --mirror +func (e *Engine) cloneMirror(url, path string) error { + if _, err := os.Stat(path); err == nil { + // Directory exists, skip + return nil + } + + cmd := exec.Command("git", "clone", "--mirror", url, path) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("clone failed: %w - %s", err, string(output)) + } + return nil +} + +// fetchMirror performs a git fetch --all --prune in a mirror repo +func (e *Engine) fetchMirror(path string) error { + cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fetch failed: %w - %s", err, string(output)) + } + return nil +} + +// finishLog updates a sync log entry +func (e *Engine) finishLog(log *database.SyncLog, status, message string) { + now := time.Now() + log.Status = status + log.Message = message + log.FinishedAt = &now + database.UpdateSyncLog(log.ID, status, message, log.FinishedAt) +} + +// IsSyncing checks if a server is currently syncing +func (e *Engine) IsSyncing(serverID int64) bool { + e.mu.Lock() + defer e.mu.Unlock() + _, active := e.activeTasks[serverID] + return active +} + +// helper to get repo ID for logging +func getRepoID(serverID int64, fullName string) *int64 { + repo, _ := database.GetRepoByFullName(serverID, fullName) + if repo != nil { + return &repo.ID + } + return nil +} +``` + +- [ ] **Step 2: Fix imports and issues** + +Edit `internal/sync/engine.go`: + +```go +package sync + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "gitm/internal/database" + db "gitm/internal/database" + "gitm/internal/gitea" +) +``` + +- [ ] **Step 3: Create scheduler** + +Create `internal/sync/scheduler.go`: + +```go +package sync + +import ( + "log" + "time" + + "github.com/robfig/cron/v3" + "gitm/internal/database" +) + +// Scheduler manages scheduled sync tasks +type Scheduler struct { + cron *cron.Cron + engine *Engine + serverJobs map[int64]cron.EntryID +} + +// NewScheduler creates a new scheduler +func NewScheduler(engine *Engine) *Scheduler { + return &Scheduler{ + cron: cron.New(), + engine: engine, + serverJobs: make(map[int64]cron.EntryID), + } +} + +// Start starts the scheduler +func (s *Scheduler) Start() { + s.cron.Start() + log.Println("Scheduler started") +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + s.cron.Stop() + log.Println("Scheduler stopped") +} + +// UpdateServer updates or removes a scheduled job for a server +func (s *Scheduler) UpdateServer(server *database.GiteaServer) { + // Remove existing job + if entryID, exists := s.serverJobs[server.ID]; exists { + s.cron.Remove(entryID) + delete(s.serverJobs, server.ID) + } + + // Skip if manual sync only or disabled + if server.SyncInterval <= 0 || server.Status != "active" { + return + } + + // Add new job - run every N minutes + schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval) + entryID, err := s.cron.AddFunc(schedule, func() { + log.Printf("Scheduled sync triggered for server %d (%s)", server.ID, server.Name) + if err := s.engine.SyncServer(server.ID); err != nil { + log.Printf("Scheduled sync failed for server %d: %v", server.ID, err) + } + }) + + if err != nil { + log.Printf("Failed to schedule job for server %d: %v", server.ID, err) + return + } + + s.serverJobs[server.ID] = entryID + log.Printf("Scheduled sync every %d minutes for server %d (%s)", server.SyncInterval, server.ID, server.Name) +} + +// RemoveServer removes a scheduled job for a server +func (s *Scheduler) RemoveServer(serverID int64) { + if entryID, exists := s.serverJobs[serverID]; exists { + s.cron.Remove(entryID) + delete(s.serverJobs, serverID) + log.Printf("Removed scheduled job for server %d", serverID) + } +} + +// ReloadAll reloads all server schedules from database +func (s *Scheduler) ReloadAll() error { + servers, err := database.GetServers() + if err != nil { + return err + } + + for _, server := range servers { + s.UpdateServer(&server) + } + + return nil +} +``` + +- [ ] **Step 4: Add missing import to scheduler** + +Edit `internal/sync/scheduler.go`: + +```go +import ( + "fmt" + "log" + "time" + + "github.com/robfig/cron/v3" + "gitm/internal/database" +) +``` + +- [ ] **Step 5: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 6: Commit** + +```bash +git add internal/sync/ +git commit -m "feat: implement sync engine and scheduler" +``` + +--- + +## Phase 3: HTTP Handlers + +### Task 7: Implement Server Management Handlers + +**Files:** +- Create: `internal/handler/server.go` + +- [ ] **Step 1: Create server handler** + +Create `internal/handler/server.go`: + +```go +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gitm/internal/database" + "gitm/internal/gitea" + "gitm/internal/sync" +) + +// HandleListServers returns all configured Gitea servers +func HandleListServers(c *gin.Context) { + servers, err := database.GetServers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, servers) +} + +// CreateServerRequest defines the request to create a server +type CreateServerRequest struct { + Name string `json:"name" binding:"required"` + URL string `json:"url" binding:"required"` + Token string `json:"token" binding:"required"` + SyncInterval int `json:"sync_interval"` +} + +// HandleCreateServer adds a new Gitea server +func HandleCreateServer(c *gin.Context) { + var req CreateServerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + server := &database.GiteaServer{ + Name: req.Name, + URL: req.URL, + Token: req.Token, + SyncInterval: req.SyncInterval, + Status: "active", + } + + if err := database.CreateServer(server); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, server) +} + +// UpdateServerRequest defines the request to update a server +type UpdateServerRequest struct { + Name *string `json:"name"` + URL *string `json:"url"` + Token *string `json:"token"` + SyncInterval *int `json:"sync_interval"` + Status *string `json:"status"` +} + +// HandleUpdateServer updates a Gitea server configuration +func HandleUpdateServer(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + server, err := database.GetServer(id) + if err != nil || server == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"}) + return + } + + var req UpdateServerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name != nil { + server.Name = *req.Name + } + if req.URL != nil { + server.URL = *req.URL + } + if req.Token != nil { + server.Token = *req.Token + } + if req.SyncInterval != nil { + server.SyncInterval = *req.SyncInterval + } + if req.Status != nil { + server.Status = *req.Status + } + + if err := database.UpdateServer(server); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, server) +} + +// HandleDeleteServer removes a Gitea server +func HandleDeleteServer(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + if err := database.DeleteServer(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Server deleted"}) +} + +// TestConnectionRequest defines the request to test a server connection +type TestConnectionRequest struct { + URL string `json:"url" binding:"required"` + Token string `json:"token" binding:"required"` +} + +// HandleTestConnection tests connection to a Gitea server +func HandleTestConnection(c *gin.Context) { + var req TestConnectionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := gitea.NewClient(req.URL, req.Token) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := client.ValidateToken() + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Connection successful", + "user": user.Login, + }) +} + +// HandleListRepos returns repositories for a server +func HandleListRepos(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + repos, err := database.GetReposByServer(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, repos) +} + +// HandleDiscoverRepos triggers repo discovery from Gitea API +func HandleDiscoverRepos(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + server, err := database.GetServer(id) + if err != nil || server == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"}) + return + } + + client, err := gitea.NewClient(server.URL, server.Token) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + giteaRepos, err := client.GetAllRepos() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get repos directory + reposDir, _ := database.GetSetting("repos_dir") + if reposDir == "" { + reposDir = "./data/repos" + } + serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name + + discovered := 0 + for _, gr := range giteaRepos { + // Check if already exists + existing, _ := database.GetRepoByFullName(id, gr.FullName) + if existing == nil { + repo := &database.Repo{ + ServerID: id, + Name: gr.Name, + FullName: gr.FullName, + CloneURL: gr.CloneURL, + LocalPath: serverDir + "/" + gr.FullName + ".git", + SyncStatus: "pending", + } + database.CreateOrUpdateRepo(repo) + discovered++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Discovered %d new repositories", discovered), + "total_repos": len(giteaRepos), + "new_repos": discovered, + }) +} + +// HandleSyncServer triggers a sync for a specific server +func HandleSyncServer(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { + return func(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + if engine.IsSyncing(id) { + c.JSON(http.StatusConflict, gin.H{"error": "Sync already in progress"}) + return + } + + go engine.SyncServer(id) + + c.JSON(http.StatusAccepted, gin.H{ + "message": "Sync started", + "server_id": id, + }) + } +} + +// HandleSyncAll triggers sync for all active servers +func HandleSyncAll(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { + return func(c *gin.Context) { + servers, err := database.GetServers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + started := 0 + for _, server := range servers { + if server.Status == "active" && !engine.IsSyncing(server.ID) { + go engine.SyncServer(server.ID) + started++ + } + } + + c.JSON(http.StatusAccepted, gin.H{ + "message": fmt.Sprintf("Started sync for %d servers", started), + }) + } +} + +// HandleGetSyncStatus returns sync status for a server +func HandleGetSyncStatus(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { + return func(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) + return + } + + if engine.IsSyncing(id) { + c.JSON(http.StatusOK, gin.H{ + "server_id": id, + "status": "syncing", + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "server_id": id, + "status": "idle", + }) + } + } +} +``` + +- [ ] **Step 2: Add missing import** + +Edit `internal/handler/server.go`, add `"fmt"` to imports: + +```go +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gitm/internal/database" + "gitm/internal/gitea" + "gitm/internal/sync" +) +``` + +- [ ] **Step 3: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 4: Commit** + +```bash +git add internal/handler/server.go +git commit -m "feat: implement server management handlers" +``` + +--- + +### Task 8: Implement Log and Stats Handlers + +**Files:** +- Create: `internal/handler/log.go` + +- [ ] **Step 1: Create log handler** + +Create `internal/handler/log.go`: + +```go +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gitm/internal/database" +) + +// HandleGetSyncLogs returns sync logs with pagination +func HandleGetSyncLogs(c *gin.Context) { + serverIDStr := c.Query("server_id") + pageStr := c.DefaultQuery("page", "1") + limitStr := c.DefaultQuery("limit", "50") + + page, _ := strconv.Atoi(pageStr) + limit, _ := strconv.Atoi(limitStr) + if limit > 100 { + limit = 100 + } + + var serverID int64 = 0 + if serverIDStr != "" { + serverID, _ = strconv.ParseInt(serverIDStr, 10, 64) + } + + offset := (page - 1) * limit + + logs, err := database.GetSyncLogs(serverID, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": logs, + "page": page, + "limit": limit, + "count": len(logs), + }) +} + +// HandleGetStats returns dashboard statistics +func HandleGetStats(c *gin.Context) { + servers, err := database.GetServers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + serverCount := len(servers) + repoCount := 0 + totalSize := int64(0) + activeServers := 0 + + for _, server := range servers { + if server.Status == "active" { + activeServers++ + } + + repos, err := database.GetReposByServer(server.ID) + if err == nil { + repoCount += len(repos) + for _, repo := range repos { + totalSize += repo.Size + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "server_count": serverCount, + "active_servers": activeServers, + "repo_count": repoCount, + "total_size": totalSize, + }) +} +``` + +- [ ] **Step 2: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/handler/log.go +git commit -m "feat: implement sync log and stats handlers" +``` + +--- + +### Task 9: Wire Up Main Server + +**Files:** +- Modify: `main.go` + +- [ ] **Step 1: Update main.go with full server implementation** + +Replace `main.go` with: + +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/gin-gonic/gin" + "gitm/internal/config" + "gitm/internal/database" + "gitm/internal/handler" + "gitm/internal/middleware" + "gitm/internal/sync" +) + +var ( + flagAddr = flag.String("addr", "", "Listen address") + flagDataDir = flag.String("data", "", "Data directory") + flagInit = flag.Bool("init", false, "Initialize database and set password") +) + +func main() { + flag.Parse() + + cfg := config.Get() + + if *flagDataDir != "" { + cfg.SetDataDir(*flagDataDir) + } + if *flagAddr != "" { + cfg.ListenAddr = *flagAddr + } + + // Ensure directories exist + if err := cfg.EnsureDirs(); err != nil { + log.Fatalf("Failed to create directories: %v", err) + } + + // Initialize database + if err := database.Initialize(cfg.DBPath); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer database.Close() + + // Load settings to config + if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" { + cfg.ListenAddr = listenAddr + } + + // Get max concurrent setting + maxConcurrent := 3 + if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" { + fmt.Sscanf(maxStr, "%d", &maxConcurrent) + } + + // Initialize components + engine := sync.NewEngine(maxConcurrent) + scheduler := sync.NewScheduler(engine) + + // Init mode + if *flagInit { + runInitMode() + return + } + + // Check if initialized + if _, err := database.GetSetting("admin_password"); err != nil { + fmt.Println("Not initialized. Please run with --init flag first.") + return + } + + // Set JWT secret from a fixed key (in production, derive from password) + middleware.SetJWTSecret("gitm-default-secret-change-in-production") + + // Reload scheduler with existing servers + scheduler.ReloadAll() + scheduler.Start() + defer scheduler.Stop() + + // Start server + log.Printf("GitM starting on %s", cfg.ListenAddr) + log.Fatal(runServer(cfg, engine, scheduler)) +} + +func runInitMode() { + var password string + fmt.Print("Enter admin password: ") + fmt.Scanln(&password) + + if password == "" { + log.Fatal("Password cannot be empty") + } + + // Hash password manually for init + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("Failed to hash password: %v", err) + } + + if err := database.SetSetting("admin_password", string(hash)); err != nil { + log.Fatalf("Failed to save password: %v", err) + } + + // Set defaults + database.SetSetting("listen_addr", ":9000") + database.SetSetting("max_concurrent", "3") + + fmt.Println("Initialized successfully!") +} + +func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error { + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + + // API routes + api := r.Group("/api") + { + // Public routes + api.POST("/login", handler.HandleLogin) + api.POST("/init", handler.HandleInit) + + // Protected routes + protected := api.Group("") + protected.Use(middleware.AuthMiddleware()) + { + // Settings + protected.GET("/settings", handler.HandleGetSettings) + protected.PUT("/settings", handler.HandleUpdateSettings) + + // Servers + protected.GET("/servers", handler.HandleListServers) + protected.POST("/servers", handler.HandleCreateServer) + protected.PUT("/servers/:id", handler.HandleUpdateServer) + protected.DELETE("/servers/:id", handler.HandleDeleteServer) + protected.POST("/servers/:id/test", handler.HandleTestConnection) + protected.GET("/servers/:id/repos", handler.HandleListRepos) + protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos) + protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine)) + protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine)) + + // Sync + protected.POST("/sync/all", handler.HandleSyncAll(engine)) + + // Logs and stats + protected.GET("/sync/logs", handler.HandleGetSyncLogs) + protected.GET("/sync/stats", handler.HandleGetStats) + } + } + + // Serve static files (frontend) - will be added when frontend is built + // r.Static("/", "./web/dist") + + return r.Run(cfg.ListenAddr) +} +``` + +- [ ] **Step 2: Add missing imports** + +Edit `main.go`, add `"golang.org/x/crypto/bcrypt"` to imports: + +```go +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gitm/internal/config" + "gitm/internal/database" + "gitm/internal/handler" + "gitm/internal/middleware" + "gitm/internal/sync" +) +``` + +- [ ] **Step 3: Test compilation** + +```bash +go build -o /tmp/gitm-test . +``` + +Expected: Success + +- [ ] **Step 4: Test basic run** + +```bash +/tmp/gitm-test --init +``` + +Expected: Prompts for password (type one, then Enter) + +- [ ] **Step 5: Commit** + +```bash +git add main.go +git commit -m "feat: wire up main server with all routes" +``` + +--- + +## Phase 4: Frontend Implementation + +### Task 10: Initialize Vue 3 Frontend + +**Files:** +- Create: `web/package.json` +- Create: `web/vite.config.ts` +- Create: `web/tsconfig.json` +- Create: `web/index.html` +- Create: `web/src/main.ts` +- Create: `web/src/App.vue` + +- [ ] **Step 1: Create web/package.json** + +Create `web/package.json`: + +```json +{ + "name": "gitm-web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "element-plus": "^2.5.0", + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vue-tsc": "^1.8.27", + "vite": "^5.0.0" + } +} +``` + +- [ ] **Step 2: Create web/vite.config.ts** + +Create `web/vite.config.ts`: + +```typescript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:9000', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets' + } +}) +``` + +- [ ] **Step 3: Create web/tsconfig.json** + +Create `web/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Step 4: Create web/tsconfig.node.json** + +Create `web/tsconfig.node.json`: + +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Step 5: Create web/index.html** + +Create `web/index.html`: + +```html + + +
+ + +Gitea Repository Sync Tool
+