From 6320f5129528325083832f0f7513b5d4b237a5a9 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 8 Jun 2026 13:12:18 -0500 Subject: [PATCH] Implement Phase 2 usability features --- cmd/gitocean/main.go | 1569 +++++++++++++++++++++++++++++++++++++++--- test | 1 - 2 files changed, 1488 insertions(+), 82 deletions(-) delete mode 160000 test diff --git a/cmd/gitocean/main.go b/cmd/gitocean/main.go index 241f526..7920d7d 100644 --- a/cmd/gitocean/main.go +++ b/cmd/gitocean/main.go @@ -43,17 +43,21 @@ type User struct { ID int64 `json:"id"` Email string `json:"email"` Username string `json:"username"` + IsAdmin bool `json:"is_admin"` } type Repository struct { - ID int64 `json:"id"` - OwnerUserID int64 `json:"-"` - Owner string `json:"owner"` - Name string `json:"name"` - Visibility string `json:"visibility"` - ForkedFromID *int64 `json:"forked_from_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + OwnerUserID int64 `json:"-"` + Owner string `json:"owner"` + Name string `json:"name"` + Visibility string `json:"visibility"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Archived bool `json:"archived"` + ForkedFromID *int64 `json:"forked_from_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PullRequest struct { @@ -79,8 +83,46 @@ type PullRequest struct { } type Server struct { - db *sql.DB - storage string + db *sql.DB + storage string + publicURL string +} + +type ServerConfig struct { + Addr string `json:"addr"` + PublicURL string `json:"public_url"` + Storage string `json:"storage"` + MySQLDSN string `json:"mysql_dsn"` +} + +type RefInfo struct { + Name string `json:"name"` + Commit string `json:"commit"` + Date time.Time `json:"date"` + Message string `json:"message"` +} + +type Collaborator struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +type PRComment struct { + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TokenInfo struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + Active bool `json:"active"` } type Config struct { @@ -98,6 +140,8 @@ func main() { var err error switch os.Args[1] { + case "init": + err = cliInit(os.Args[2:]) case "server": err = runServer(os.Args[2:]) case "register": @@ -114,6 +158,12 @@ func main() { err = cliRepo(os.Args[2:]) case "pr": err = cliPR(os.Args[2:]) + case "token": + err = cliToken(os.Args[2:]) + case "admin": + err = cliAdmin(os.Args[2:]) + case "backup": + err = cliBackup(os.Args[2:]) case "help", "-h", "--help": usage() default: @@ -129,39 +179,84 @@ func usage() { fmt.Println(`gitocean - CLI and server for a small Git hosting platform Commands: - gitocean server --dsn DSN [--addr :8080] [--storage storage] + gitocean init [--config storage/config.json] + gitocean server [--config storage/config.json] [--dsn DSN] [--addr :8080] [--storage storage] gitocean register [--server URL] gitocean login [--server URL] gitocean logout gitocean whoami gitocean clone OWNER/REPO - gitocean repo create NAME (--public|--private) + gitocean repo create NAME (--public|--private) [--description TEXT] + gitocean repo publish NAME (--public|--private) [--remote origin] [--branch main] + gitocean repo view OWNER/REPO + gitocean repo branches OWNER/REPO + gitocean repo tags OWNER/REPO + gitocean repo set OWNER/REPO [--description TEXT] [--visibility public|private] [--default-branch main] gitocean repo delete OWNER/REPO [--force] gitocean repo search QUERY [--all|--mine] gitocean repo fork OWNER/REPO [--name NEW_NAME] + gitocean repo collaborators OWNER/REPO + gitocean repo collaborator add OWNER/REPO USER --role read|write + gitocean repo collaborator remove OWNER/REPO USER gitocean pr create --from OWNER/REPO:BRANCH --to OWNER/REPO:BRANCH --title TITLE [--description DESC] gitocean pr create --repo OWNER/REPO --from BRANCH --to BRANCH --title TITLE [--description DESC] gitocean pr list OWNER/REPO gitocean pr view OWNER/REPO NUMBER + gitocean pr diff OWNER/REPO NUMBER + gitocean pr checkout OWNER/REPO NUMBER + gitocean pr comment OWNER/REPO NUMBER "text" + gitocean pr comments OWNER/REPO NUMBER gitocean pr close OWNER/REPO NUMBER - gitocean pr merge OWNER/REPO NUMBER`) + gitocean pr merge OWNER/REPO NUMBER + gitocean token list|revoke|prune + gitocean admin users list + gitocean admin users delete USER + gitocean admin repos list + gitocean admin storage check + gitocean backup create FILE + gitocean backup restore FILE`) } // ---------------- Server boot and migrations ---------------- func runServer(args []string) error { fs := flag.NewFlagSet("server", flag.ExitOnError) - addr := fs.String("addr", ":8080", "HTTP listen address") + configFile := fs.String("config", defaultServerConfigPath(), "server config file") + addr := fs.String("addr", "", "HTTP listen address") dsn := fs.String("dsn", os.Getenv("GITOOCEAN_DSN"), "MySQL DSN") - storage := fs.String("storage", defaultStorage, "storage directory") + storage := fs.String("storage", "", "storage directory") + publicURL := fs.String("public-url", "", "public server URL") if err := fs.Parse(args); err != nil { return err } - if *dsn == "" { - return errors.New("--dsn or GITOOCEAN_DSN is required") + + cfg, _ := loadServerConfig(*configFile) + if *addr != "" { + cfg.Addr = *addr + } + if *dsn != "" { + cfg.MySQLDSN = *dsn + } + if *storage != "" { + cfg.Storage = *storage + } + if *publicURL != "" { + cfg.PublicURL = *publicURL + } + if cfg.Addr == "" { + cfg.Addr = ":8080" + } + if cfg.Storage == "" { + cfg.Storage = defaultStorage + } + if cfg.PublicURL == "" { + cfg.PublicURL = defaultServerURL + } + if cfg.MySQLDSN == "" { + return errors.New("--dsn, GITOOCEAN_DSN, or server config mysql_dsn is required; run gitocean init") } - db, err := openMySQLAndCreateDatabaseIfMissing(*dsn) + db, err := openMySQLAndCreateDatabaseIfMissing(cfg.MySQLDSN) if err != nil { return err } @@ -169,13 +264,13 @@ func runServer(args []string) error { if err := migrate(db); err != nil { return err } - if err := os.MkdirAll(filepath.Join(*storage, "repos"), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(cfg.Storage, "repos"), 0755); err != nil { return err } - s := &Server{db: db, storage: *storage} - fmt.Printf("gitocean listening on %s, storage=%s\n", *addr, *storage) - return http.ListenAndServe(*addr, s) + s := &Server{db: db, storage: cfg.Storage, publicURL: strings.TrimRight(cfg.PublicURL, "/")} + fmt.Printf("gitocean listening on %s, storage=%s, public_url=%s\n", cfg.Addr, cfg.Storage, s.publicURL) + return http.ListenAndServe(cfg.Addr, s) } func openMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) { @@ -238,6 +333,7 @@ func migrate(db *sql.DB) error { email VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS auth_tokens ( @@ -256,6 +352,9 @@ func migrate(db *sql.DB) error { owner_user_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, visibility ENUM('public','private') NOT NULL, + description TEXT NOT NULL, + default_branch VARCHAR(200) NOT NULL DEFAULT 'main', + archived BOOLEAN NOT NULL DEFAULT false, forked_from_repository_id BIGINT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -264,6 +363,16 @@ func migrate(db *sql.DB) error { CONSTRAINT fk_repositories_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_repositories_fork FOREIGN KEY (forked_from_repository_id) REFERENCES repositories(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS repository_collaborators ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + repository_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role ENUM('read','write') NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_repo_user (repository_id, user_id), + CONSTRAINT fk_collab_repo FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, + CONSTRAINT fk_collab_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS pull_requests ( id BIGINT AUTO_INCREMENT PRIMARY KEY, target_repository_id BIGINT NOT NULL, @@ -286,12 +395,34 @@ func migrate(db *sql.DB) error { CONSTRAINT fk_pr_source FOREIGN KEY (source_repository_id) REFERENCES repositories(id) ON DELETE CASCADE, CONSTRAINT fk_pr_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pull_request_comments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + pull_request_id BIGINT NOT NULL, + author_user_id BIGINT NOT NULL, + body TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_pr_comments_pr (pull_request_id), + CONSTRAINT fk_pr_comments_pr FOREIGN KEY (pull_request_id) REFERENCES pull_requests(id) ON DELETE CASCADE, + CONSTRAINT fk_pr_comments_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { return err } } + alterations := []string{ + `ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE repositories ADD COLUMN description TEXT NULL`, + `ALTER TABLE repositories ADD COLUMN default_branch VARCHAR(200) NOT NULL DEFAULT 'main'`, + `ALTER TABLE repositories ADD COLUMN archived BOOLEAN NOT NULL DEFAULT false`, + } + for _, stmt := range alterations { + if _, err := db.Exec(stmt); err != nil && !isDuplicateColumnError(err) { + return err + } + } return nil } @@ -320,6 +451,10 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { s.handleLogout(w, r) case r.Method == http.MethodGet && path == "/me": s.handleMe(w, r) + case strings.HasPrefix(path, "/tokens"): + s.handleTokenRoutes(w, r, strings.TrimPrefix(path, "/tokens")) + case strings.HasPrefix(path, "/admin"): + s.handleAdminRoutes(w, r, strings.TrimPrefix(path, "/admin")) case r.Method == http.MethodPost && path == "/repos": s.handleRepoCreate(w, r) case r.Method == http.MethodGet && path == "/repos/search": @@ -342,6 +477,8 @@ func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest switch r.Method { case http.MethodGet: s.handleRepoGet(w, r, owner, repo) + case http.MethodPatch: + s.handleRepoUpdate(w, r, owner, repo) case http.MethodDelete: s.handleRepoDelete(w, r, owner, repo) default: @@ -353,6 +490,18 @@ func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest s.handleRepoFork(w, r, owner, repo) return } + if len(parts) == 3 && parts[2] == "branches" && r.Method == http.MethodGet { + s.handleRepoBranches(w, r, owner, repo) + return + } + if len(parts) == 3 && parts[2] == "tags" && r.Method == http.MethodGet { + s.handleRepoTags(w, r, owner, repo) + return + } + if len(parts) >= 3 && parts[2] == "collaborators" { + s.handleCollaborators(w, r, owner, repo, parts[3:]) + return + } if len(parts) >= 3 && parts[2] == "pulls" { if len(parts) == 3 { switch r.Method { @@ -375,6 +524,20 @@ func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest s.handlePRView(w, r, owner, repo, n) return } + if len(parts) == 5 && r.Method == http.MethodGet && parts[4] == "diff" { + s.handlePRDiff(w, r, owner, repo, n) + return + } + if len(parts) == 5 && parts[4] == "comments" { + if r.Method == http.MethodGet { + s.handlePRCommentsList(w, r, owner, repo, n) + } else if r.Method == http.MethodPost { + s.handlePRCommentCreate(w, r, owner, repo, n) + } else { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + return + } if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "close" { s.handlePRClose(w, r, owner, repo, n) return @@ -418,13 +581,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "password hash failed") return } - res, err := s.db.Exec(`INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)`, in.Email, in.Username, string(hash)) + var userCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) + isAdmin := userCount == 0 + res, err := s.db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, in.Email, in.Username, string(hash), isAdmin) if err != nil { writeError(w, http.StatusConflict, "email or username already exists") return } id, _ := res.LastInsertId() - writeJSON(w, http.StatusCreated, User{ID: id, Email: in.Email, Username: in.Username}) + writeJSON(w, http.StatusCreated, User{ID: id, Email: in.Email, Username: in.Username, IsAdmin: isAdmin}) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { @@ -438,7 +604,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { login := strings.ToLower(strings.TrimSpace(in.Login)) var user User var hash string - err := s.db.QueryRow(`SELECT id, email, username, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &hash) + err := s.db.QueryRow(`SELECT id, email, username, is_admin, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin, &hash) if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil { writeError(w, http.StatusUnauthorized, "invalid credentials") return @@ -512,7 +678,7 @@ func (s *Server) userFromToken(token, requiredUsername string) (User, error) { return User{}, errors.New("missing token") } var user User - query := `SELECT u.id, u.email, u.username + query := `SELECT u.id, u.email, u.username, u.is_admin FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.revoked_at IS NULL AND t.expires_at > UTC_TIMESTAMP()` args := []any{hashToken(token)} @@ -520,7 +686,7 @@ func (s *Server) userFromToken(token, requiredUsername string) (User, error) { query += ` AND u.username = ?` args = append(args, requiredUsername) } - err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username) + err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin) if err != nil { return User{}, err } @@ -536,6 +702,130 @@ func (s *Server) userFromBasic(r *http.Request) (User, bool) { return user, err == nil } +func (s *Server) handleTokenRoutes(w http.ResponseWriter, r *http.Request, path string) { + user, ok := s.requireBearerUser(w, r) + if !ok { + return + } + if path == "" || path == "/" { + if r.Method == http.MethodGet { + s.handleTokenList(w, user) + return + } + } + if path == "/prune" && r.Method == http.MethodPost { + _, err := s.db.Exec(`DELETE FROM auth_tokens WHERE user_id = ? AND (expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL)`, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"}) + return + } + if strings.HasPrefix(path, "/") && r.Method == http.MethodDelete { + id, err := strconv.ParseInt(strings.TrimPrefix(path, "/"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid token id") + return + } + _, err = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE id = ? AND user_id = ?`, id, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) + return + } + writeError(w, http.StatusNotFound, "not found") +} + +func (s *Server) handleTokenList(w http.ResponseWriter, user User) { + rows, err := s.db.Query(`SELECT id, created_at, expires_at, revoked_at, revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() FROM auth_tokens WHERE user_id = ? ORDER BY created_at DESC`, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + var out []TokenInfo + for rows.Next() { + var t TokenInfo + var revoked sql.NullTime + if err := rows.Scan(&t.ID, &t.CreatedAt, &t.ExpiresAt, &revoked, &t.Active); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if revoked.Valid { + t.RevokedAt = &revoked.Time + } + out = append(out, t) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleAdminRoutes(w http.ResponseWriter, r *http.Request, path string) { + user, ok := s.requireBearerUser(w, r) + if !ok { + return + } + if !user.IsAdmin { + writeError(w, http.StatusForbidden, "admin access required") + return + } + switch { + case r.Method == http.MethodDelete && strings.HasPrefix(path, "/users/"): + username := strings.TrimPrefix(path, "/users/") + if username == user.Username { + writeError(w, http.StatusBadRequest, "cannot delete yourself") + return + } + _, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, strings.ToLower(username)) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + case r.Method == http.MethodGet && path == "/users": + rows, err := s.db.Query(`SELECT id, email, username, is_admin FROM users ORDER BY username`) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, u) + } + writeJSON(w, http.StatusOK, out) + case r.Method == http.MethodGet && path == "/repos": + rows, err := s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id ORDER BY u.username, r.name`) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + repos, err := scanRepos(rows) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, repos) + case r.Method == http.MethodPost && path == "/tokens/prune": + _, err := s.db.Exec(`DELETE FROM auth_tokens WHERE expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL`) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"}) + default: + writeError(w, http.StatusNotFound, "not found") + } +} + // ---------------- Repository API ---------------- func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) { @@ -544,8 +834,9 @@ func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) { return } var in struct { - Name string `json:"name"` - Visibility string `json:"visibility"` + Name string `json:"name"` + Visibility string `json:"visibility"` + Description string `json:"description"` } if !decodeJSON(w, r, &in) { return @@ -560,7 +851,7 @@ func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "visibility must be public or private") return } - res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility) VALUES (?, ?, ?)`, user.ID, name, visibility) + res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch) VALUES (?, ?, ?, ?, 'main')`, user.ID, name, visibility, strings.TrimSpace(in.Description)) if err != nil { writeError(w, http.StatusConflict, "repository already exists") return @@ -577,7 +868,7 @@ func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusCreated, Repository{ID: repoID, OwnerUserID: user.ID, Owner: user.Username, Name: name, Visibility: visibility}) + writeJSON(w, http.StatusCreated, Repository{ID: repoID, OwnerUserID: user.ID, Owner: user.Username, Name: name, Visibility: visibility, Description: strings.TrimSpace(in.Description), DefaultBranch: "main"}) } func (s *Server) handleRepoSearch(w http.ResponseWriter, r *http.Request) { @@ -595,18 +886,18 @@ func (s *Server) handleRepoSearch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "scope=mine requires auth") return } - rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at + rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE r.owner_user_id = ? AND (? = '' OR r.name LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, user.ID, q, like) } else if scope == "all" { if authed { - rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at + rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id - WHERE (r.visibility = 'public' OR r.owner_user_id = ?) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) - ORDER BY r.updated_at DESC LIMIT 100`, user.ID, q, like, like) + WHERE (r.visibility = 'public' OR r.owner_user_id = ? OR EXISTS (SELECT 1 FROM repository_collaborators c WHERE c.repository_id = r.id AND c.user_id = ?)) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) + ORDER BY r.updated_at DESC LIMIT 100`, user.ID, user.ID, q, like, like) } else { - rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at + rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE r.visibility = 'public' AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, q, like, like) @@ -635,7 +926,7 @@ func (s *Server) handleRepoGet(w http.ResponseWriter, r *http.Request, owner, na return } user, authed := s.optionalBearerUser(r) - if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) { + if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusNotFound, "repository not found") return } @@ -701,7 +992,7 @@ func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, n writeError(w, http.StatusBadRequest, "invalid repository name") return } - res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, forked_from_repository_id) VALUES (?, ?, ?, ?)`, user.ID, newName, src.Visibility, src.ID) + res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch, forked_from_repository_id) VALUES (?, ?, ?, ?, ?, ?)`, user.ID, newName, src.Visibility, src.Description, src.DefaultBranch, src.ID) if err != nil { writeError(w, http.StatusConflict, "repository already exists") return @@ -720,15 +1011,15 @@ func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, n writeError(w, http.StatusInternalServerError, string(out)) return } - writeJSON(w, http.StatusCreated, Repository{ID: newID, OwnerUserID: user.ID, Owner: user.Username, Name: newName, Visibility: src.Visibility, ForkedFromID: &src.ID}) + writeJSON(w, http.StatusCreated, Repository{ID: newID, OwnerUserID: user.ID, Owner: user.Username, Name: newName, Visibility: src.Visibility, Description: src.Description, DefaultBranch: src.DefaultBranch, ForkedFromID: &src.ID}) } func (s *Server) loadRepo(owner, name string) (Repository, error) { var repo Repository var fork sql.NullInt64 - err := s.db.QueryRow(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at + err := s.db.QueryRow(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id - WHERE u.username = ? AND r.name = ?`, strings.ToLower(owner), strings.ToLower(name)).Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &fork, &repo.CreatedAt, &repo.UpdatedAt) + WHERE u.username = ? AND r.name = ?`, strings.ToLower(owner), strings.ToLower(name)).Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &repo.Description, &repo.DefaultBranch, &repo.Archived, &fork, &repo.CreatedAt, &repo.UpdatedAt) if fork.Valid { repo.ForkedFromID = &fork.Int64 } @@ -740,7 +1031,7 @@ func scanRepos(rows *sql.Rows) ([]Repository, error) { for rows.Next() { var repo Repository var fork sql.NullInt64 - if err := rows.Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &fork, &repo.CreatedAt, &repo.UpdatedAt); err != nil { + if err := rows.Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &repo.Description, &repo.DefaultBranch, &repo.Archived, &fork, &repo.CreatedAt, &repo.UpdatedAt); err != nil { return nil, err } if fork.Valid { @@ -751,6 +1042,210 @@ func scanRepos(rows *sql.Rows) ([]Repository, error) { return repos, rows.Err() } +func (s *Server) handleRepoUpdate(w http.ResponseWriter, r *http.Request, owner, name string) { + user, ok := s.requireBearerUser(w, r) + if !ok { + return + } + repo, err := s.loadRepo(owner, name) + if err != nil { + writeError(w, http.StatusNotFound, "repository not found") + return + } + if repo.OwnerUserID != user.ID { + writeError(w, http.StatusForbidden, "only the owner can update this repository") + return + } + var in struct { + Description *string `json:"description"` + Visibility *string `json:"visibility"` + DefaultBranch *string `json:"default_branch"` + Archived *bool `json:"archived"` + } + if !decodeJSON(w, r, &in) { + return + } + if in.Description != nil { + repo.Description = strings.TrimSpace(*in.Description) + } + if in.Visibility != nil { + v := strings.ToLower(strings.TrimSpace(*in.Visibility)) + if v != "public" && v != "private" { + writeError(w, http.StatusBadRequest, "visibility must be public or private") + return + } + repo.Visibility = v + } + if in.DefaultBranch != nil { + b := strings.TrimSpace(*in.DefaultBranch) + if !branchRE.MatchString(b) || !gitBranchExists(s.repoPath(repo.Owner, repo.Name), b) { + writeError(w, http.StatusBadRequest, "default branch must exist") + return + } + repo.DefaultBranch = b + _ = runGit("", "--git-dir", s.repoPath(repo.Owner, repo.Name), "symbolic-ref", "HEAD", "refs/heads/"+b) + } + if in.Archived != nil { + repo.Archived = *in.Archived + } + _, err = s.db.Exec(`UPDATE repositories SET description = ?, visibility = ?, default_branch = ?, archived = ? WHERE id = ?`, repo.Description, repo.Visibility, repo.DefaultBranch, repo.Archived, repo.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, repo) +} + +func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request, owner, name string) { + repo, ok := s.requireReadableRepo(w, r, owner, name) + if !ok { + return + } + refs, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, refs) +} + +func (s *Server) handleRepoTags(w http.ResponseWriter, r *http.Request, owner, name string) { + repo, ok := s.requireReadableRepo(w, r, owner, name) + if !ok { + return + } + refs, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/tags") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, refs) +} + +func (s *Server) requireReadableRepo(w http.ResponseWriter, r *http.Request, owner, name string) (Repository, bool) { + repo, err := s.loadRepo(owner, name) + if err != nil { + writeError(w, http.StatusNotFound, "repository not found") + return Repository{}, false + } + user, authed := s.optionalBearerUser(r) + if !s.canReadRepo(repo, user, authed) { + writeError(w, http.StatusNotFound, "repository not found") + return Repository{}, false + } + return repo, true +} + +func (s *Server) canReadRepo(repo Repository, user User, authed bool) bool { + if repo.Visibility == "public" { + return true + } + if !authed { + return false + } + if user.ID == repo.OwnerUserID { + return true + } + return s.collaboratorRole(repo.ID, user.ID) != "" +} + +func (s *Server) canWriteRepo(repo Repository, user User) bool { + if user.ID == repo.OwnerUserID { + return true + } + return s.collaboratorRole(repo.ID, user.ID) == "write" +} + +func (s *Server) collaboratorRole(repoID, userID int64) string { + var role string + _ = s.db.QueryRow(`SELECT role FROM repository_collaborators WHERE repository_id = ? AND user_id = ?`, repoID, userID).Scan(&role) + return role +} + +func (s *Server) handleCollaborators(w http.ResponseWriter, r *http.Request, owner, name string, parts []string) { + user, ok := s.requireBearerUser(w, r) + if !ok { + return + } + repo, err := s.loadRepo(owner, name) + if err != nil { + writeError(w, http.StatusNotFound, "repository not found") + return + } + if repo.OwnerUserID != user.ID { + writeError(w, http.StatusForbidden, "only the owner can manage collaborators") + return + } + if len(parts) == 0 && r.Method == http.MethodGet { + s.handleCollaboratorsList(w, repo) + return + } + if len(parts) == 0 && r.Method == http.MethodPost { + s.handleCollaboratorAdd(w, r, repo) + return + } + if len(parts) == 1 && r.Method == http.MethodDelete { + _, err := s.db.Exec(`DELETE rc FROM repository_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repository_id = ? AND u.username = ?`, repo.ID, strings.ToLower(parts[0])) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) + return + } + writeError(w, http.StatusNotFound, "not found") +} + +func (s *Server) handleCollaboratorsList(w http.ResponseWriter, repo Repository) { + rows, err := s.db.Query(`SELECT rc.id, u.username, rc.role, rc.created_at FROM repository_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repository_id = ? ORDER BY u.username`, repo.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + var out []Collaborator + for rows.Next() { + var c Collaborator + if err := rows.Scan(&c.ID, &c.Username, &c.Role, &c.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, c) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCollaboratorAdd(w http.ResponseWriter, r *http.Request, repo Repository) { + var in struct { + Username string `json:"username"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &in) { + return + } + username := strings.ToLower(strings.TrimSpace(in.Username)) + role := strings.ToLower(strings.TrimSpace(in.Role)) + if role != "read" && role != "write" { + writeError(w, http.StatusBadRequest, "role must be read or write") + return + } + var userID int64 + if err := s.db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&userID); err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + if userID == repo.OwnerUserID { + writeError(w, http.StatusBadRequest, "owner is already a collaborator") + return + } + _, err := s.db.Exec(`INSERT INTO repository_collaborators (repository_id, user_id, role) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE role = VALUES(role)`, repo.ID, userID, role) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"username": username, "role": role}) +} + // ---------------- Pull request API ---------------- func (s *Server) handlePRCreate(w http.ResponseWriter, r *http.Request, targetOwner, targetName string) { @@ -838,7 +1333,7 @@ func (s *Server) handlePRList(w http.ResponseWriter, r *http.Request, owner, nam return } user, authed := s.optionalBearerUser(r) - if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) { + if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusNotFound, "repository not found") return } @@ -863,7 +1358,7 @@ func (s *Server) handlePRView(w http.ResponseWriter, r *http.Request, owner, nam return } user, authed := s.optionalBearerUser(r) - if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) { + if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusNotFound, "repository not found") return } @@ -1019,6 +1514,116 @@ func (s *Server) mergePR(pr PullRequest) error { return nil } +func (s *Server) handlePRDiff(w http.ResponseWriter, r *http.Request, owner, name string, number int) { + repo, ok := s.requireReadableRepo(w, r, owner, name) + if !ok { + return + } + pr, err := s.loadPR(repo.ID, number) + if err != nil { + writeError(w, http.StatusNotFound, "pull request not found") + return + } + diff, err := s.prDiff(pr) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(diff)) +} + +func (s *Server) prDiff(pr PullRequest) (string, error) { + tmp, err := os.MkdirTemp("", "gitocean-diff-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmp) + work := filepath.Join(tmp, "work") + if err := runGit("", "clone", "--no-checkout", s.repoPath(pr.TargetOwner, pr.TargetRepo), work); err != nil { + return "", err + } + if err := runGit(work, "remote", "add", "source", s.repoPath(pr.SourceOwner, pr.SourceRepo)); err != nil { + return "", err + } + if err := runGit(work, "fetch", "origin", pr.TargetBranch); err != nil { + return "", err + } + if err := runGit(work, "fetch", "source", pr.SourceBranch); err != nil { + return "", err + } + cmd := exec.Command("git", "diff", "--patch", "origin/"+pr.TargetBranch+"...FETCH_HEAD") + cmd.Dir = work + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git diff failed: %s", strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func (s *Server) handlePRCommentsList(w http.ResponseWriter, r *http.Request, owner, name string, number int) { + repo, ok := s.requireReadableRepo(w, r, owner, name) + if !ok { + return + } + pr, err := s.loadPR(repo.ID, number) + if err != nil { + writeError(w, http.StatusNotFound, "pull request not found") + return + } + rows, err := s.db.Query(`SELECT c.id, u.username, c.body, c.created_at, c.updated_at FROM pull_request_comments c JOIN users u ON u.id = c.author_user_id WHERE c.pull_request_id = ? ORDER BY c.created_at`, pr.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + var out []PRComment + for rows.Next() { + var c PRComment + if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.CreatedAt, &c.UpdatedAt); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, c) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handlePRCommentCreate(w http.ResponseWriter, r *http.Request, owner, name string, number int) { + user, ok := s.requireBearerUser(w, r) + if !ok { + return + } + repo, err := s.loadRepo(owner, name) + if err != nil || !s.canReadRepo(repo, user, true) { + writeError(w, http.StatusNotFound, "repository not found") + return + } + pr, err := s.loadPR(repo.ID, number) + if err != nil { + writeError(w, http.StatusNotFound, "pull request not found") + return + } + var in struct { + Body string `json:"body"` + } + if !decodeJSON(w, r, &in) { + return + } + body := strings.TrimSpace(in.Body) + if body == "" { + writeError(w, http.StatusBadRequest, "comment body is required") + return + } + res, err := s.db.Exec(`INSERT INTO pull_request_comments (pull_request_id, author_user_id, body) VALUES (?, ?, ?)`, pr.ID, user.ID, body) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + id, _ := res.LastInsertId() + writeJSON(w, http.StatusCreated, PRComment{ID: id, Author: user.Username, Body: body, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}) +} + // ---------------- Git HTTP ---------------- func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) { @@ -1040,8 +1645,12 @@ func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "authentication required") return } - if user.ID != repo.OwnerUserID { - writeError(w, http.StatusForbidden, "only owner can push") + if repo.Archived { + writeError(w, http.StatusForbidden, "repository is archived") + return + } + if !s.canWriteRepo(repo, user) { + writeError(w, http.StatusForbidden, "write access required") return } } else { @@ -1051,7 +1660,7 @@ func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "authentication required") return } - if user.ID != repo.OwnerUserID { + if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusForbidden, "not allowed") return } @@ -1159,6 +1768,47 @@ func writeCGIResponse(w http.ResponseWriter, data []byte) { // ---------------- CLI ---------------- +func cliInit(args []string) error { + fs := flag.NewFlagSet("init", flag.ExitOnError) + configFile := fs.String("config", defaultServerConfigPath(), "server config file") + if err := fs.Parse(args); err != nil { + return err + } + cfg := ServerConfig{} + cfg.Addr = promptDefault("Listen address", ":8080") + cfg.PublicURL = strings.TrimRight(promptDefault("Public URL", defaultServerURL), "/") + cfg.Storage = promptDefault("Storage directory", defaultStorage) + cfg.MySQLDSN = promptDefault("MySQL DSN", "root:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true") + + db, err := openMySQLAndCreateDatabaseIfMissing(cfg.MySQLDSN) + if err != nil { + return err + } + defer db.Close() + if err := migrate(db); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(cfg.Storage, "repos"), 0755); err != nil { + return err + } + if strings.EqualFold(promptDefault("Create first admin user?", "n"), "y") { + email := strings.ToLower(prompt("Email: ")) + username := strings.ToLower(prompt("Username: ")) + password := prompt("Password: ") + if err := createUserDirect(db, email, username, password, true); err != nil { + return err + } + fmt.Printf("Created admin user %s\n", username) + } + if err := saveServerConfig(*configFile, cfg); err != nil { + return err + } + fmt.Printf("Initialized Gitocean at %s\n\n", *configFile) + fmt.Println("Start the server with:") + fmt.Printf(" gitocean server --config %s\n", *configFile) + return nil +} + func cliRegister(args []string) error { fs := flag.NewFlagSet("register", flag.ExitOnError) server := fs.String("server", envServer(), "server URL") @@ -1247,58 +1897,206 @@ func cliClone(args []string) error { func cliRepo(args []string) error { if len(args) < 1 { - return errors.New("usage: gitocean repo ") + return errors.New("usage: gitocean repo ") } switch args[0] { case "create": return cliRepoCreate(args[1:]) + case "publish": + return cliRepoPublish(args[1:]) + case "view": + return cliRepoView(args[1:]) + case "branches": + return cliRepoRefs(args[1:], "branches") + case "tags": + return cliRepoRefs(args[1:], "tags") + case "set": + return cliRepoSet(args[1:]) + case "archive": + return cliRepoArchive(args[1:], true) + case "unarchive": + return cliRepoArchive(args[1:], false) case "delete": return cliRepoDelete(args[1:]) case "search": return cliRepoSearch(args[1:]) case "fork": return cliRepoFork(args[1:]) + case "collaborators": + return cliRepoCollaborators(args[1:]) + case "collaborator": + return cliRepoCollaborator(args[1:]) default: return fmt.Errorf("unknown repo command %q", args[0]) } } func cliRepoCreate(args []string) error { - var name string - pub, priv := false, false - for _, arg := range args { - switch arg { - case "--public", "-public": - pub = true - case "--private", "-private": - priv = true - default: - if strings.HasPrefix(arg, "-") { - return fmt.Errorf("unknown flag %s", arg) - } - if name != "" { - return errors.New("usage: gitocean repo create NAME (--public|--private)") - } - name = arg - } - } - if name == "" || pub == priv { - return errors.New("usage: gitocean repo create NAME (--public|--private)") - } - visibility := "private" - if pub { - visibility = "public" + name, visibility, description, jsonOut, err := parseRepoCreateArgs(args) + if err != nil { + return err } cfg, err := loadConfig() if err != nil { return err } var out Repository - if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility}, &out); err != nil { + if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility, "description": description}, &out); err != nil { return err } - printJSON(out) - fmt.Printf("remote: %s/%s/%s.git\n", strings.TrimRight(cfg.Server, "/"), out.Owner, out.Name) + if jsonOut { + printJSON(out) + return nil + } + printRepoCreated(cfg.Server, out) + return nil +} + +func cliRepoPublish(args []string) error { + name, visibility, description, remoteName, branch, err := parseRepoPublishArgs(args) + if err != nil { + return err + } + if err := runGit("", "rev-parse", "--is-inside-work-tree"); err != nil { + return errors.New("repo publish must be run inside a Git repository") + } + cfg, err := loadConfig() + if err != nil { + return err + } + var out Repository + if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility, "description": description}, &out); err != nil { + return err + } + remote := repoCloneURL(cfg.Server, out.Owner, out.Name) + if err := runGit("", "remote", "get-url", remoteName); err != nil { + if err := runGit("", "remote", "add", remoteName, remote); err != nil { + return err + } + } else if err := runGit("", "remote", "set-url", remoteName, remote); err != nil { + return err + } + _ = runGit("", "branch", "-M", branch) + cmd := exec.Command("git", "push", "-u", remoteName, branch) + cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin + if err := cmd.Run(); err != nil { + return err + } + if branch != out.DefaultBranch { + body := map[string]any{"default_branch": branch} + _ = apiRequest(context.Background(), cfg.Server, http.MethodPatch, fmt.Sprintf("/api/repos/%s/%s", out.Owner, out.Name), cfg.Token, body, &out) + } + fmt.Printf("Published %s/%s to %s\n", out.Owner, out.Name, remote) + return nil +} + +func cliRepoView(args []string) error { + jsonOut := removeBoolFlag(&args, "--json") + if len(args) != 1 { + return errors.New("usage: gitocean repo view OWNER/REPO") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + server, token := envServer(), "" + if cfg, err := loadConfig(); err == nil { + server, token = cfg.Server, cfg.Token + } + var out Repository + if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s", owner, repo), token, nil, &out); err != nil { + return err + } + if jsonOut { + printJSON(out) + return nil + } + printRepoView(server, out) + return nil +} + +func cliRepoRefs(args []string, kind string) error { + jsonOut := removeBoolFlag(&args, "--json") + if len(args) != 1 { + return fmt.Errorf("usage: gitocean repo %s OWNER/REPO", kind) + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + server, token := envServer(), "" + if cfg, err := loadConfig(); err == nil { + server, token = cfg.Server, cfg.Token + } + var out []RefInfo + if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/%s", owner, repo, kind), token, nil, &out); err != nil { + return err + } + if jsonOut { + printJSON(out) + return nil + } + printRefs(kind, out) + return nil +} + +func cliRepoSet(args []string) error { + if len(args) < 1 { + return errors.New("usage: gitocean repo set OWNER/REPO [--description TEXT] [--visibility public|private] [--default-branch main]") + } + repoRef := args[0] + body := map[string]any{} + for i := 1; i < len(args); i++ { + switch args[i] { + case "--description": + if i+1 >= len(args) { + return errors.New("--description requires a value") + } + i++ + body["description"] = args[i] + case "--visibility": + if i+1 >= len(args) { + return errors.New("--visibility requires a value") + } + i++ + body["visibility"] = args[i] + case "--default-branch": + if i+1 >= len(args) { + return errors.New("--default-branch requires a value") + } + i++ + body["default_branch"] = args[i] + default: + return fmt.Errorf("unknown flag %s", args[i]) + } + } + if len(body) == 0 { + return errors.New("no settings provided") + } + return cliRepoPatch(repoRef, body) +} + +func cliRepoArchive(args []string, archived bool) error { + if len(args) != 1 { + return errors.New("usage: gitocean repo archive OWNER/REPO") + } + return cliRepoPatch(args[0], map[string]any{"archived": archived}) +} + +func cliRepoPatch(repoRef string, body map[string]any) error { + owner, repo, err := splitOwnerRepo(repoRef) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + var out Repository + if err := apiRequest(context.Background(), cfg.Server, http.MethodPatch, fmt.Sprintf("/api/repos/%s/%s", owner, repo), cfg.Token, body, &out); err != nil { + return err + } + printRepoView(cfg.Server, out) return nil } @@ -1371,7 +2169,7 @@ func cliRepoSearch(args []string) error { if err := apiRequest(context.Background(), server, http.MethodGet, path, token, nil, &out); err != nil { return err } - printJSON(out) + printRepoTable(out) return nil } @@ -1420,9 +2218,72 @@ func cliRepoFork(args []string) error { return nil } +func cliRepoCollaborators(args []string) error { + if len(args) != 1 { + return errors.New("usage: gitocean repo collaborators OWNER/REPO") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + var out []Collaborator + if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/collaborators", owner, repo), cfg.Token, nil, &out); err != nil { + return err + } + for _, c := range out { + fmt.Printf("%-20s %s\n", c.Username, c.Role) + } + return nil +} + +func cliRepoCollaborator(args []string) error { + if len(args) < 1 { + return errors.New("usage: gitocean repo collaborator ") + } + switch args[0] { + case "add": + if len(args) != 5 || args[3] != "--role" { + return errors.New("usage: gitocean repo collaborator add OWNER/REPO USER --role read|write") + } + owner, repo, err := splitOwnerRepo(args[1]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + var out map[string]string + if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/collaborators", owner, repo), cfg.Token, map[string]string{"username": args[2], "role": args[4]}, &out); err != nil { + return err + } + fmt.Printf("Added %s as %s\n", out["username"], out["role"]) + return nil + case "remove": + if len(args) != 3 { + return errors.New("usage: gitocean repo collaborator remove OWNER/REPO USER") + } + owner, repo, err := splitOwnerRepo(args[1]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + return apiRequest(context.Background(), cfg.Server, http.MethodDelete, fmt.Sprintf("/api/repos/%s/%s/collaborators/%s", owner, repo, strings.ToLower(args[2])), cfg.Token, nil, nil) + default: + return fmt.Errorf("unknown collaborator command %q", args[0]) + } +} + func cliPR(args []string) error { if len(args) < 1 { - return errors.New("usage: gitocean pr ") + return errors.New("usage: gitocean pr ") } switch args[0] { case "create": @@ -1431,6 +2292,14 @@ func cliPR(args []string) error { return cliPRList(args[1:]) case "view": return cliPRView(args[1:]) + case "diff": + return cliPRDiff(args[1:]) + case "checkout": + return cliPRCheckout(args[1:]) + case "comment": + return cliPRComment(args[1:]) + case "comments": + return cliPRComments(args[1:]) case "close": return cliPRAction(args[1:], "close") case "merge": @@ -1482,7 +2351,7 @@ func cliPRCreate(args []string) error { if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls", targetOwner, targetRepo), cfg.Token, body, &out); err != nil { return err } - printJSON(out) + printPR(out) return nil } @@ -1502,7 +2371,9 @@ func cliPRList(args []string) error { if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls", owner, repo), token, nil, &out); err != nil { return err } - printJSON(out) + for _, pr := range out { + fmt.Printf("#%-4d %-8s %-30s %s/%s:%s -> %s\n", pr.Number, pr.Status, pr.Title, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch, pr.TargetBranch) + } return nil } @@ -1522,7 +2393,97 @@ func cliPRView(args []string) error { if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s", owner, repo, args[1]), token, nil, &out); err != nil { return err } - printJSON(out) + printPR(out) + return nil +} + +func cliPRDiff(args []string) error { + if len(args) != 2 { + return errors.New("usage: gitocean pr diff OWNER/REPO NUMBER") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + server, token := envServer(), "" + if cfg, err := loadConfig(); err == nil { + server, token = cfg.Server, cfg.Token + } + text, err := apiTextRequest(context.Background(), server, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/diff", owner, repo, args[1]), token) + if err != nil { + return err + } + fmt.Print(text) + return nil +} + +func cliPRCheckout(args []string) error { + if len(args) != 2 { + return errors.New("usage: gitocean pr checkout OWNER/REPO NUMBER") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + server, token := envServer(), "" + if cfg, err := loadConfig(); err == nil { + server, token = cfg.Server, cfg.Token + } + var pr PullRequest + if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s", owner, repo, args[1]), token, nil, &pr); err != nil { + return err + } + branch := "pr-" + args[1] + remote := repoCloneURL(server, pr.SourceOwner, pr.SourceRepo) + if err := runGit("", "fetch", remote, pr.SourceBranch+":"+branch); err != nil { + _ = runGit("", "branch", "-D", branch) + if err := runGit("", "fetch", remote, pr.SourceBranch+":"+branch); err != nil { + return err + } + } + return runGit("", "checkout", branch) +} + +func cliPRComment(args []string) error { + if len(args) < 3 { + return errors.New("usage: gitocean pr comment OWNER/REPO NUMBER \"text\"") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + body := strings.Join(args[2:], " ") + var out PRComment + if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/comments", owner, repo, args[1]), cfg.Token, map[string]string{"body": body}, &out); err != nil { + return err + } + fmt.Printf("Commented as %s\n", out.Author) + return nil +} + +func cliPRComments(args []string) error { + if len(args) != 2 { + return errors.New("usage: gitocean pr comments OWNER/REPO NUMBER") + } + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + server, token := envServer(), "" + if cfg, err := loadConfig(); err == nil { + server, token = cfg.Server, cfg.Token + } + var out []PRComment + if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/comments", owner, repo, args[1]), token, nil, &out); err != nil { + return err + } + for _, c := range out { + fmt.Printf("%s by %s:\n%s\n\n", c.CreatedAt.Format(time.RFC3339), c.Author, c.Body) + } return nil } @@ -1542,10 +2503,113 @@ func cliPRAction(args []string, action string) error { if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/%s", owner, repo, args[1], action), cfg.Token, nil, &out); err != nil { return err } - printJSON(out) + printPR(out) return nil } +func cliToken(args []string) error { + if len(args) < 1 { + return errors.New("usage: gitocean token ") + } + cfg, err := loadConfig() + if err != nil { + return err + } + switch args[0] { + case "list": + var out []TokenInfo + if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/tokens", cfg.Token, nil, &out); err != nil { + return err + } + for _, t := range out { + status := "revoked/expired" + if t.Active { + status = "active" + } + fmt.Printf("%-6d %-15s created=%s expires=%s\n", t.ID, status, t.CreatedAt.Format(time.RFC3339), t.ExpiresAt.Format(time.RFC3339)) + } + return nil + case "revoke": + if len(args) != 2 { + return errors.New("usage: gitocean token revoke TOKEN_ID") + } + return apiRequest(context.Background(), cfg.Server, http.MethodDelete, "/api/tokens/"+args[1], cfg.Token, nil, nil) + case "prune": + return apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/tokens/prune", cfg.Token, nil, nil) + default: + return fmt.Errorf("unknown token command %q", args[0]) + } +} + +func cliAdmin(args []string) error { + if len(args) < 2 { + return errors.New("usage: gitocean admin users list | repos list | storage check | tokens prune") + } + cfg, err := loadConfig() + if err != nil { + return err + } + switch args[0] + " " + args[1] { + case "users list": + var out []User + if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/admin/users", cfg.Token, nil, &out); err != nil { + return err + } + for _, u := range out { + fmt.Printf("%-20s %-30s admin=%v\n", u.Username, u.Email, u.IsAdmin) + } + return nil + case "users delete": + if len(args) != 3 { + return errors.New("usage: gitocean admin users delete USER") + } + return apiRequest(context.Background(), cfg.Server, http.MethodDelete, "/api/admin/users/"+strings.ToLower(args[2]), cfg.Token, nil, nil) + case "repos list": + var out []Repository + if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/admin/repos", cfg.Token, nil, &out); err != nil { + return err + } + printRepoTable(out) + return nil + case "storage check": + serverCfg, err := loadServerConfig(defaultServerConfigPath()) + if err != nil { + return err + } + return filepath.Walk(filepath.Join(serverCfg.Storage, "repos"), func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info != nil && info.IsDir() && strings.HasSuffix(path, ".git") { + fmt.Println(path) + } + return nil + }) + case "tokens prune": + return apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/admin/tokens/prune", cfg.Token, nil, nil) + default: + return errors.New("usage: gitocean admin users list | repos list | storage check | tokens prune") + } +} + +func cliBackup(args []string) error { + if len(args) != 2 { + return errors.New("usage: gitocean backup create FILE | restore FILE") + } + cfg, err := loadServerConfig(defaultServerConfigPath()) + if err != nil { + return err + } + switch args[0] { + case "create": + return createBackup(cfg, args[1]) + case "restore": + return restoreBackup(cfg, args[1]) + default: + return errors.New("usage: gitocean backup create FILE | restore FILE") + } +} + // ---------------- CLI helpers ---------------- func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error { @@ -1579,6 +2643,9 @@ func apiRequest(ctx context.Context, server, method, path, token string, body an Error string `json:"error"` } if json.Unmarshal(b, &e) == nil && e.Error != "" { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("%s\nYour session may have expired. Run:\n gitocean login", e.Error) + } return fmt.Errorf("%s", e.Error) } return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) @@ -1595,6 +2662,9 @@ func envServer() string { if v := os.Getenv("GITOOCEAN_SERVER"); v != "" { return strings.TrimRight(v, "/") } + if cfg, err := loadServerConfig(defaultServerConfigPath()); err == nil && cfg.PublicURL != "" { + return strings.TrimRight(cfg.PublicURL, "/") + } return defaultServerURL } @@ -1672,6 +2742,343 @@ func splitRepoBranch(s string) (owner, repo, branch string, err error) { return owner, repo, branch, err } +func parseRepoCreateArgs(args []string) (name, visibility, description string, jsonOut bool, err error) { + pub, priv := false, false + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--public" || arg == "-public": + pub = true + case arg == "--private" || arg == "-private": + priv = true + case arg == "--json": + jsonOut = true + case arg == "--description": + if i+1 >= len(args) { + return "", "", "", false, errors.New("--description requires a value") + } + i++ + description = args[i] + case strings.HasPrefix(arg, "--description="): + description = strings.TrimPrefix(arg, "--description=") + default: + if strings.HasPrefix(arg, "-") { + return "", "", "", false, fmt.Errorf("unknown flag %s", arg) + } + if name != "" { + return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)") + } + name = arg + } + } + if name == "" || pub == priv { + return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)") + } + visibility = "private" + if pub { + visibility = "public" + } + return name, visibility, description, jsonOut, nil +} + +func parseRepoPublishArgs(args []string) (name, visibility, description, remoteName, branch string, err error) { + remoteName = "origin" + branch = "main" + filtered := []string{} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--remote": + if i+1 >= len(args) { + return "", "", "", "", "", errors.New("--remote requires a value") + } + i++ + remoteName = args[i] + case "--branch": + if i+1 >= len(args) { + return "", "", "", "", "", errors.New("--branch requires a value") + } + i++ + branch = args[i] + default: + filtered = append(filtered, args[i]) + } + } + name, visibility, description, _, err = parseRepoCreateArgs(filtered) + if err != nil { + return "", "", "", "", "", err + } + return name, visibility, description, remoteName, branch, nil +} + +func removeBoolFlag(args *[]string, flagName string) bool { + in := *args + out := in[:0] + found := false + for _, arg := range in { + if arg == flagName { + found = true + continue + } + out = append(out, arg) + } + *args = out + return found +} + +func repoCloneURL(server, owner, repo string) string { + return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo) +} + +func printRepoCreated(server string, repo Repository) { + remote := repoCloneURL(server, repo.Owner, repo.Name) + fmt.Printf("Created %s repository %s/%s\n\n", repo.Visibility, repo.Owner, repo.Name) + fmt.Println("Clone:") + fmt.Printf(" git clone %s\n\n", remote) + fmt.Println("Add existing repo:") + fmt.Printf(" git remote add origin %s\n", remote) + fmt.Printf(" git branch -M %s\n", repo.DefaultBranch) + fmt.Printf(" git push -u origin %s\n", repo.DefaultBranch) +} + +func printRepoView(server string, repo Repository) { + fmt.Printf("%s/%s\n", repo.Owner, repo.Name) + fmt.Printf("Visibility: %s\n", repo.Visibility) + fmt.Printf("Default branch: %s\n", repo.DefaultBranch) + fmt.Printf("Archived: %v\n", repo.Archived) + if repo.Description != "" { + fmt.Printf("Description: %s\n", repo.Description) + } + fmt.Println("\nClone:") + fmt.Printf(" git clone %s\n", repoCloneURL(server, repo.Owner, repo.Name)) +} + +func printRepoTable(repos []Repository) { + fmt.Printf("%-28s %-10s %-8s %s\n", "REPOSITORY", "VISIBILITY", "ARCHIVED", "DESCRIPTION") + for _, r := range repos { + fmt.Printf("%-28s %-10s %-8v %s\n", r.Owner+"/"+r.Name, r.Visibility, r.Archived, r.Description) + } +} + +func printRefs(kind string, refs []RefInfo) { + fmt.Printf("%-28s %-10s %-20s %s\n", strings.ToUpper(strings.TrimSuffix(kind, "es")), "COMMIT", "DATE", "MESSAGE") + for _, r := range refs { + commit := r.Commit + if len(commit) > 10 { + commit = commit[:10] + } + fmt.Printf("%-28s %-10s %-20s %s\n", r.Name, commit, r.Date.Format("2006-01-02 15:04"), r.Message) + } +} + +func printPR(pr PullRequest) { + fmt.Printf("#%d %s: %s\n", pr.Number, pr.Status, pr.Title) + fmt.Printf("Author: %s\n", pr.Author) + fmt.Printf("From: %s/%s:%s\n", pr.SourceOwner, pr.SourceRepo, pr.SourceBranch) + fmt.Printf("To: %s/%s:%s\n", pr.TargetOwner, pr.TargetRepo, pr.TargetBranch) + if pr.Description != "" { + fmt.Printf("\n%s\n", pr.Description) + } +} + +func apiTextRequest(ctx context.Context, server, path, token string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(server, "/")+path, nil) + if err != nil { + return "", err + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + return string(b), nil +} + +func defaultServerConfigPath() string { + if v := os.Getenv("GITOOCEAN_SERVER_CONFIG"); v != "" { + return v + } + return filepath.Join(defaultStorage, "config.json") +} + +func loadServerConfig(path string) (ServerConfig, error) { + b, err := os.ReadFile(path) + if err != nil { + return ServerConfig{}, err + } + var cfg ServerConfig + if err := json.Unmarshal(b, &cfg); err != nil { + return ServerConfig{}, err + } + return cfg, nil +} + +func saveServerConfig(path string, cfg ServerConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + b, _ := json.MarshalIndent(cfg, "", " ") + return os.WriteFile(path, b, 0600) +} + +func promptDefault(label, def string) string { + fmt.Printf("%s [%s]: ", label, def) + var s string + fmt.Scanln(&s) + if strings.TrimSpace(s) == "" { + return def + } + return strings.TrimSpace(s) +} + +func createUserDirect(db *sql.DB, email, username, password string, isAdmin bool) error { + if !strings.Contains(email, "@") || !usernameRE.MatchString(username) || len(password) < 8 { + return errors.New("invalid email, username, or password") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, email, username, string(hash), isAdmin) + return err +} + +func gitRefs(repoPath, prefix string) ([]RefInfo, error) { + cmd := exec.Command("git", "--git-dir", repoPath, "for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(committerdate:iso8601)%00%(subject)", prefix) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("git refs failed: %s", strings.TrimSpace(string(out))) + } + var refs []RefInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\x00", 4) + if len(parts) < 4 { + continue + } + date, _ := time.Parse("2006-01-02 15:04:05 -0700", parts[2]) + refs = append(refs, RefInfo{Name: parts[0], Commit: parts[1], Date: date, Message: parts[3]}) + } + return refs, nil +} + +func createBackup(cfg ServerConfig, file string) error { + tmp, err := os.MkdirTemp("", "gitocean-backup-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + metadata := filepath.Join(tmp, "metadata.sql") + mysqlArgs, dbName, err := mysqlCLIArgs(cfg.MySQLDSN) + if err != nil { + return err + } + cmd := exec.Command("mysqldump", append(mysqlArgs, dbName)...) + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("mysqldump failed: %w", err) + } + if err := os.WriteFile(metadata, out, 0600); err != nil { + return err + } + cmd = exec.Command("tar", "-czf", file, "-C", ".", cfg.Storage, "-C", tmp, "metadata.sql") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +func restoreBackup(cfg ServerConfig, file string) error { + tmp, err := os.MkdirTemp("", "gitocean-restore-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + cmd := exec.Command("tar", "-xzf", file, "-C", tmp) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return err + } + mysqlArgs, dbName, err := mysqlCLIArgs(cfg.MySQLDSN) + if err != nil { + return err + } + metadata := filepath.Join(tmp, "metadata.sql") + f, err := os.Open(metadata) + if err != nil { + return err + } + defer f.Close() + cmd = exec.Command("mysql", append(mysqlArgs, dbName)...) + cmd.Stdin = f + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return copyDir(filepath.Join(tmp, cfg.Storage), cfg.Storage) +} + +func mysqlCLIArgs(dsn string) ([]string, string, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, "", err + } + args := []string{} + if cfg.User != "" { + args = append(args, "-u"+cfg.User) + } + if cfg.Passwd != "" { + args = append(args, "-p"+cfg.Passwd) + } + if cfg.Net == "tcp" && cfg.Addr != "" { + host, port := cfg.Addr, "" + if strings.Contains(cfg.Addr, ":") { + host, port, _ = strings.Cut(cfg.Addr, ":") + } + if host != "" { + args = append(args, "-h"+host) + } + if port != "" { + args = append(args, "-P"+port) + } + } else if cfg.Net == "unix" && cfg.Addr != "" { + args = append(args, "--socket", cfg.Addr) + } + return args, cfg.DBName, nil +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + to := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(to, info.Mode()) + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(to, b, info.Mode()) + }) +} + +func isDuplicateColumnError(err error) bool { + var mysqlErr *mysql.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 +} + // ---------------- Shared helpers ---------------- func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool { diff --git a/test b/test deleted file mode 160000 index b00f141..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b00f1418894b084cb84ff9db7a6917ba934a0a32