Refactor codebase for Phase 3

This commit is contained in:
2026-06-08 13:34:53 -05:00
parent 6320f51295
commit c32e08be38
32 changed files with 4718 additions and 3323 deletions
-194
View File
@@ -1,194 +0,0 @@
# Gitocean Implementation Plan
## Product decisions
- Build a Git repository hosting platform in Go.
- Store bare Git repositories under `storage/repos/{owner}/{repo}.git`.
- Use MySQL for metadata.
- Support HTTP Smart Git only. SSH is out of scope for MVP.
- Support CLI + API only. No web UI.
- Registration is open, but registration happens through the CLI/API only.
- Users register with email, username, and password.
- Users can log in with either email + password or username + password.
- Login returns a temporary auth token that expires after 7 days.
- The CLI stores this token locally for API calls and can approve it into Git's credential helper for Git HTTP operations.
- Repository names allow lowercase letters, numbers, `.`, `_`, and `-`.
- Usernames allow lowercase letters, numbers, `_`, and `-`.
- New repositories use `main` as the default branch.
- Pull request numbers are per target repository.
## Permissions
### Repository visibility
- `public`: anyone can clone/fetch. Only the owner can push/delete/manage.
- `private`: only the owner can clone/fetch/push/delete/manage.
### Writes
- MVP is owner-only.
- There are no collaborators, organizations, teams, or fine-grained permissions yet.
- Open collaboration is supported through pull requests against public repositories.
## Storage layout
```text
storage/
repos/
alice/
demo.git/
bob/
project.git/
```
Each repository directory is a bare Git repository.
## MySQL schema
Core tables:
- `users`
- id
- email
- username
- password_hash
- created_at
- `auth_tokens`
- id
- user_id
- token_hash
- expires_at
- revoked_at
- created_at
- `repositories`
- id
- owner_user_id
- name
- visibility: `public` or `private`
- forked_from_repository_id nullable
- created_at
- updated_at
- `pull_requests`
- id
- target_repository_id
- number
- author_user_id
- source_repository_id
- source_branch
- target_branch
- title
- description
- status: `open`, `closed`, or `merged`
- closed_at nullable
- merged_at nullable
- created_at
- updated_at
## API
### Auth
```http
POST /api/register
POST /api/login
POST /api/logout
GET /api/me
```
### Repositories
```http
POST /api/repos
GET /api/repos/search?q=demo&scope=all
GET /api/repos/search?q=demo&scope=mine
GET /api/repos/{owner}/{repo}
DELETE /api/repos/{owner}/{repo}?force=false
POST /api/repos/{owner}/{repo}/fork
```
### Pull requests
```http
POST /api/repos/{owner}/{repo}/pulls
GET /api/repos/{owner}/{repo}/pulls
GET /api/repos/{owner}/{repo}/pulls/{number}
POST /api/repos/{owner}/{repo}/pulls/{number}/close
POST /api/repos/{owner}/{repo}/pulls/{number}/merge
```
### Git HTTP
```http
/{owner}/{repo}.git/info/refs
/{owner}/{repo}.git/git-upload-pack
/{owner}/{repo}.git/git-receive-pack
```
## CLI commands
```bash
gitocean server --addr :8080 --dsn 'user:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true' --storage storage
gitocean register
gitocean login
gitocean logout
gitocean whoami
gitocean repo create NAME --public
gitocean repo create NAME --private
gitocean repo delete OWNER/NAME [--force]
gitocean repo search QUERY --all
gitocean repo search QUERY --mine
gitocean repo fork OWNER/NAME [--name NEW_NAME]
gitocean clone OWNER/NAME
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 close OWNER/REPO NUMBER
gitocean pr merge OWNER/REPO NUMBER
```
## Pull request rules
### Same-repository PRs
- Source and target repository are the same.
- Only the repository owner can create same-repo PRs.
- Source and target branches must both exist.
### Cross-repository PRs
- Source repository branch points to target repository branch.
- Source repository must be owned by the PR author.
- Target repository must be public, unless the author is also the target owner.
- Source and target branches must both exist.
- Only the target repository owner can close or merge the PR.
## Merge behavior
MVP merge mode is a normal merge commit:
1. Clone target repo into a temporary worktree.
2. Checkout target branch.
3. Add/fetch source repo branch.
4. Run `git merge --no-ff FETCH_HEAD`.
5. Push the result back to the target branch.
6. Mark PR as merged.
If conflicts occur, return an error and keep the PR open.
## Implementation milestones
1. Project scaffold and build setup.
2. MySQL migrations.
3. User registration, login, logout, and token auth.
4. Repository create, delete, detail, search.
5. HTTP Smart Git integration using `git http-backend`.
6. CLI auth and repository commands.
7. Fork support.
8. Pull request create/list/view/close.
9. Pull request merge.
10. Tests, hardening, and UX polish.
+64
View File
@@ -0,0 +1,64 @@
# Gitocean
Gitocean is a small Go-based Git hosting platform for personal use. It provides a CLI, JSON HTTP API, MySQL metadata storage, and HTTP Smart Git transport. There is no web UI and no SSH transport.
## Quickstart
```bash
go run ./cmd/gitocean init
go run ./cmd/gitocean server --config storage/config.json
```
In another shell:
```bash
go run ./cmd/gitocean register
go run ./cmd/gitocean login
go run ./cmd/gitocean repo create demo --public
go run ./cmd/gitocean clone USER/demo
```
## Storage
Bare repositories are stored under:
```text
storage/repos/{owner}/{repo}.git
```
Server config defaults to:
```text
storage/config.json
```
CLI auth config defaults to:
```text
~/.config/gitocean/config.json
```
## Development
Run checks with:
```bash
gofmt -w .
go test ./...
go vet ./...
```
## Code layout
```text
cmd/gitocean/ thin executable entrypoint
internal/app/ application command dispatch and domain handlers
internal/model/ shared data models
internal/config/ client/server config helpers
internal/dbutil/ MySQL connection helpers
internal/gitutil/ reusable Git command helpers
internal/httputil/ JSON HTTP helpers
internal/backup/ backup/restore helpers
internal/validate/ name and branch validators
plans/ phase plans
```
+3 -3127
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
package app
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"golang.org/x/crypto/bcrypt"
"net/http"
"strconv"
"strings"
"time"
)
// ---------------- Auth API ----------------
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
if !strings.Contains(in.Email, "@") || len(in.Email) > 255 {
writeError(w, http.StatusBadRequest, "invalid email")
return
}
if !usernameRE.MatchString(in.Username) || isReservedName(in.Username) {
writeError(w, http.StatusBadRequest, "invalid or reserved username")
return
}
if len(in.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "password hash failed")
return
}
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, IsAdmin: isAdmin})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Login string `json:"login"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
login := strings.ToLower(strings.TrimSpace(in.Login))
var user User
var hash string
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
}
token, expiresAt, err := s.createToken(user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "token creation failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": user, "token": token, "expires_at": expiresAt})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
_, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(token))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) createToken(userID int64) (string, time.Time, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, err
}
token := "go_" + base64.RawURLEncoding.EncodeToString(raw)
expiresAt := time.Now().UTC().Add(tokenTTL)
_, err := s.db.Exec(`INSERT INTO auth_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, userID, hashToken(token), expiresAt)
return token, expiresAt, err
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
func (s *Server) requireBearerUser(w http.ResponseWriter, r *http.Request) (User, bool) {
user, err := s.userFromToken(bearerToken(r), "")
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return User{}, false
}
return user, true
}
func (s *Server) optionalBearerUser(r *http.Request) (User, bool) {
user, err := s.userFromToken(bearerToken(r), "")
return user, err == nil
}
func (s *Server) userFromToken(token, requiredUsername string) (User, error) {
if token == "" {
return User{}, errors.New("missing token")
}
var user User
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)}
if requiredUsername != "" {
query += ` AND u.username = ?`
args = append(args, requiredUsername)
}
err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin)
if err != nil {
return User{}, err
}
return user, nil
}
func (s *Server) userFromBasic(r *http.Request) (User, bool) {
username, token, ok := r.BasicAuth()
if !ok {
return User{}, false
}
user, err := s.userFromToken(token, strings.ToLower(username))
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")
}
}
+123
View File
@@ -0,0 +1,123 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func cliToken(args []string) error {
if len(args) < 1 {
return errors.New("usage: gitocean token <list|revoke|prune>")
}
cfg, err := loadConfig()
if err != nil {
return err
}
switch args[0] {
case "list":
jsonOut := len(args) > 1 && args[1] == "--json"
if len(args) > 2 || (len(args) == 2 && !jsonOut) {
return errors.New("usage: gitocean token list [--json]")
}
var out []TokenInfo
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/tokens", cfg.Token, nil, &out); err != nil {
return err
}
if jsonOut {
printJSON(out)
return nil
}
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")
}
}
+104
View File
@@ -0,0 +1,104 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
func cliRegister(args []string) error {
fs := flag.NewFlagSet("register", flag.ExitOnError)
server := fs.String("server", envServer(), "server URL")
if err := fs.Parse(args); err != nil {
return err
}
email := prompt("Email: ")
username := prompt("Username: ")
password := prompt("Password: ")
var out User
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/register", "", map[string]string{"email": email, "username": username, "password": password}, &out); err != nil {
return err
}
fmt.Printf("Registered %s <%s>\n", out.Username, out.Email)
if out.IsAdmin {
fmt.Println("This is the first user, so it is an admin account.")
}
return nil
}
func cliLogin(args []string) error {
fs := flag.NewFlagSet("login", flag.ExitOnError)
server := fs.String("server", envServer(), "server URL")
if err := fs.Parse(args); err != nil {
return err
}
login := prompt("Email or username: ")
password := prompt("Password: ")
var out struct {
User User `json:"user"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/login", "", map[string]string{"login": login, "password": password}, &out); err != nil {
return err
}
cfg := Config{Server: strings.TrimRight(*server, "/"), Username: out.User.Username, Token: out.Token, ExpiresAt: out.ExpiresAt}
if err := saveConfig(cfg); err != nil {
return err
}
approveGitCredential(cfg)
fmt.Printf("logged in as %s; token expires %s\n", cfg.Username, cfg.ExpiresAt.Format(time.RFC3339))
return nil
}
func cliLogout(args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
_ = apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/logout", cfg.Token, nil, nil)
_ = os.Remove(configPath())
fmt.Println("logged out")
return nil
}
func cliWhoami(args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
var user User
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/me", cfg.Token, nil, &user); err != nil {
return err
}
fmt.Printf("Username: %s\n", user.Username)
fmt.Printf("Email: %s\n", user.Email)
fmt.Printf("Admin: %v\n", user.IsAdmin)
return nil
}
func cliClone(args []string) error {
if len(args) != 1 {
return errors.New("usage: gitocean clone OWNER/REPO")
}
server := envServer()
if cfg, err := loadConfig(); err == nil && cfg.Server != "" {
server = cfg.Server
}
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
remote := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo)
cmd := exec.Command("git", "clone", remote)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
+341
View File
@@ -0,0 +1,341 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"gitocean/internal/backup"
appconfig "gitocean/internal/config"
"gitocean/internal/dbutil"
"gitocean/internal/gitutil"
"golang.org/x/crypto/bcrypt"
"io"
"net/http"
"net/url"
"os/exec"
"strings"
)
// ---------------- CLI helpers ----------------
func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error {
server = strings.TrimRight(server, "/")
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, server+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
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 {
var e struct {
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)))
}
if out != nil && len(b) > 0 {
if err := json.Unmarshal(b, out); err != nil {
return err
}
}
return nil
}
func envServer() string {
return appconfig.EnvServer()
}
func configPath() string {
return appconfig.ClientConfigPath()
}
func loadConfig() (Config, error) {
return appconfig.LoadClient()
}
func saveConfig(cfg Config) error {
return appconfig.SaveClient(cfg)
}
func approveGitCredential(cfg Config) {
u, err := url.Parse(cfg.Server)
if err != nil || u.Host == "" || u.Scheme == "" {
return
}
cmd := exec.Command("git", "credential", "approve")
cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n", u.Scheme, u.Host, cfg.Username, cfg.Token))
_ = cmd.Run()
}
func prompt(label string) string {
fmt.Print(label)
var s string
fmt.Scanln(&s)
return strings.TrimSpace(s)
}
func printJSON(v any) {
b, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(b))
}
func splitOwnerRepo(s string) (string, string, error) {
parts := strings.Split(s, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("expected OWNER/REPO, got %q", s)
}
return strings.ToLower(parts[0]), strings.ToLower(parts[1]), nil
}
func splitRepoBranch(s string) (owner, repo, branch string, err error) {
left, branch, ok := strings.Cut(s, ":")
if !ok || branch == "" {
return "", "", "", fmt.Errorf("expected OWNER/REPO:BRANCH, got %q", s)
}
owner, repo, err = splitOwnerRepo(left)
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 {
return appconfig.DefaultServerConfigPath()
}
func loadServerConfig(path string) (ServerConfig, error) {
return appconfig.LoadServer(path)
}
func saveServerConfig(path string, cfg ServerConfig) error {
return appconfig.SaveServer(path, cfg)
}
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) {
return gitutil.Refs(repoPath, prefix)
}
func createBackup(cfg ServerConfig, file string) error {
return backup.Create(cfg, file)
}
func restoreBackup(cfg ServerConfig, file string) error {
return backup.Restore(cfg, file)
}
func mysqlCLIArgs(dsn string) ([]string, string, error) {
return backup.MySQLCLIArgs(dsn)
}
func copyDir(src, dst string) error {
return backup.CopyDir(src, dst)
}
func isDuplicateColumnError(err error) bool {
return dbutil.IsDuplicateColumnError(err)
}
+52
View File
@@ -0,0 +1,52 @@
package app
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
// ---------------- 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
}
+247
View File
@@ -0,0 +1,247 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"strings"
"time"
)
func cliPR(args []string) error {
if len(args) < 1 {
return errors.New("usage: gitocean pr <create|list|view|diff|checkout|comment|comments|close|merge>")
}
switch args[0] {
case "create":
return cliPRCreate(args[1:])
case "list":
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":
return cliPRAction(args[1:], "merge")
default:
return fmt.Errorf("unknown pr command %q", args[0])
}
}
func cliPRCreate(args []string) error {
fs := flag.NewFlagSet("pr create", flag.ExitOnError)
repoFlag := fs.String("repo", "", "target repo for same-repository PR")
from := fs.String("from", "", "source branch or OWNER/REPO:BRANCH")
to := fs.String("to", "", "target branch or OWNER/REPO:BRANCH")
title := fs.String("title", "", "title")
desc := fs.String("description", "", "description")
if err := fs.Parse(args); err != nil {
return err
}
if *from == "" || *to == "" || *title == "" {
return errors.New("usage: gitocean pr create --from SRC --to DST --title TITLE [--description DESC]")
}
var targetOwner, targetRepo, targetBranch, sourceOwner, sourceRepo, sourceBranch string
if *repoFlag != "" {
var err error
targetOwner, targetRepo, err = splitOwnerRepo(*repoFlag)
if err != nil {
return err
}
sourceOwner, sourceRepo = targetOwner, targetRepo
sourceBranch, targetBranch = *from, *to
} else {
var err error
sourceOwner, sourceRepo, sourceBranch, err = splitRepoBranch(*from)
if err != nil {
return err
}
targetOwner, targetRepo, targetBranch, err = splitRepoBranch(*to)
if err != nil {
return err
}
}
cfg, err := loadConfig()
if err != nil {
return err
}
body := map[string]string{"source_owner": sourceOwner, "source_repo": sourceRepo, "source_branch": sourceBranch, "target_branch": targetBranch, "title": *title, "description": *desc}
var out PullRequest
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
}
printPR(out)
return nil
}
func cliPRList(args []string) error {
jsonOut := removeBoolFlag(&args, "--json")
if len(args) != 1 {
return errors.New("usage: gitocean pr list 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 []PullRequest
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls", owner, repo), token, nil, &out); err != nil {
return err
}
if jsonOut {
printJSON(out)
return nil
}
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
}
func cliPRView(args []string) error {
jsonOut := removeBoolFlag(&args, "--json")
if len(args) != 2 {
return errors.New("usage: gitocean pr view 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 PullRequest
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
}
if jsonOut {
printJSON(out)
return nil
}
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
}
func cliPRAction(args []string, action string) error {
if len(args) != 2 {
return fmt.Errorf("usage: gitocean pr %s OWNER/REPO NUMBER", action)
}
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
var out PullRequest
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
}
printPR(out)
return nil
}
+403
View File
@@ -0,0 +1,403 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
)
func cliRepo(args []string) error {
if len(args) < 1 {
return errors.New("usage: gitocean repo <create|publish|view|branches|tags|set|archive|unarchive|delete|search|fork|collaborators|collaborator>")
}
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 {
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, "description": description}, &out); err != nil {
return err
}
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
}
func cliRepoDelete(args []string) error {
var repoRef string
force := false
for _, arg := range args {
switch arg {
case "--force", "-force":
force = true
default:
if strings.HasPrefix(arg, "-") {
return fmt.Errorf("unknown flag %s", arg)
}
if repoRef != "" {
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
}
repoRef = arg
}
}
if repoRef == "" {
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
}
owner, repo, err := splitOwnerRepo(repoRef)
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
path := fmt.Sprintf("/api/repos/%s/%s?force=%v", owner, repo, force)
if err := apiRequest(context.Background(), cfg.Server, http.MethodDelete, path, cfg.Token, nil, nil); err != nil {
return err
}
fmt.Printf("Deleted %s/%s\n", owner, repo)
return nil
}
func cliRepoSearch(args []string) error {
jsonOut := removeBoolFlag(&args, "--json")
var query string
scope := "all"
for _, arg := range args {
switch arg {
case "--mine", "-mine":
scope = "mine"
case "--all", "-all":
scope = "all"
default:
if strings.HasPrefix(arg, "-") {
return fmt.Errorf("unknown flag %s", arg)
}
if query != "" {
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
}
query = arg
}
}
if query == "" {
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
}
server := envServer()
token := ""
if cfg, err := loadConfig(); err == nil {
server, token = cfg.Server, cfg.Token
}
path := "/api/repos/search?q=" + url.QueryEscape(query) + "&scope=" + scope
var out []Repository
if err := apiRequest(context.Background(), server, http.MethodGet, path, token, nil, &out); err != nil {
return err
}
if jsonOut {
printJSON(out)
return nil
}
printRepoTable(out)
return nil
}
func cliRepoFork(args []string) error {
var repoRef string
newName := ""
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--name" || arg == "-name":
if i+1 >= len(args) {
return errors.New("--name requires a value")
}
i++
newName = args[i]
case strings.HasPrefix(arg, "--name="):
newName = strings.TrimPrefix(arg, "--name=")
case strings.HasPrefix(arg, "-name="):
newName = strings.TrimPrefix(arg, "-name=")
default:
if strings.HasPrefix(arg, "-") {
return fmt.Errorf("unknown flag %s", arg)
}
if repoRef != "" {
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
}
repoRef = arg
}
}
if repoRef == "" {
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
}
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.MethodPost, fmt.Sprintf("/api/repos/%s/%s/fork", owner, repo), cfg.Token, map[string]string{"name": newName}, &out); err != nil {
return err
}
fmt.Printf("Forked %s/%s to %s/%s\n", owner, repo, out.Owner, out.Name)
fmt.Printf("Clone: git clone %s\n", repoCloneURL(cfg.Server, out.Owner, out.Name))
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 <add|remove>")
}
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])
}
}
+125
View File
@@ -0,0 +1,125 @@
package app
import (
"database/sql"
"errors"
"fmt"
"time"
appconfig "gitocean/internal/config"
"gitocean/internal/model"
"gitocean/internal/validate"
)
const (
defaultServerURL = appconfig.DefaultServerURL
defaultStorage = appconfig.DefaultStorage
tokenTTL = 7 * 24 * time.Hour
)
var (
ErrUsage = errors.New("usage")
usernameRE = validate.UsernameRE
repoNameRE = validate.RepoNameRE
branchRE = validate.BranchRE
)
type User = model.User
type Repository = model.Repository
type PullRequest = model.PullRequest
type ServerConfig = model.ServerConfig
type RefInfo = model.RefInfo
type Collaborator = model.Collaborator
type PRComment = model.PRComment
type TokenInfo = model.TokenInfo
type Config = model.Config
type Server struct {
db *sql.DB
storage string
publicURL string
}
func Run(args []string) error {
if len(args) < 1 {
usage()
return ErrUsage
}
switch args[0] {
case "init":
return cliInit(args[1:])
case "server":
return runServer(args[1:])
case "register":
return cliRegister(args[1:])
case "login":
return cliLogin(args[1:])
case "logout":
return cliLogout(args[1:])
case "whoami":
return cliWhoami(args[1:])
case "clone":
return cliClone(args[1:])
case "repo":
return cliRepo(args[1:])
case "pr":
return cliPR(args[1:])
case "token":
return cliToken(args[1:])
case "admin":
return cliAdmin(args[1:])
case "backup":
return cliBackup(args[1:])
case "help", "-h", "--help":
usage()
return nil
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func usage() {
fmt.Println(`gitocean - CLI and server for a small Git hosting platform
Commands:
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) [--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] [--json]
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 [--json]
gitocean pr view OWNER/REPO NUMBER [--json]
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 token list [--json]
gitocean token revoke TOKEN_ID
gitocean token 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`)
}
+154
View File
@@ -0,0 +1,154 @@
package app
import (
"bytes"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// ---------------- Git HTTP ----------------
func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) {
owner, repoName, _, ok := parseGitPath(r.URL.Path)
if !ok {
writeError(w, http.StatusNotFound, "invalid git path")
return
}
repo, err := s.loadRepo(owner, repoName)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
service := gitService(r)
user, authed := s.userFromBasic(r)
if service == "git-receive-pack" {
if !authed {
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
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 {
if repo.Visibility == "private" {
if !authed {
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
if !s.canReadRepo(repo, user, authed) {
writeError(w, http.StatusForbidden, "not allowed")
return
}
}
}
s.runGitHTTPBackend(w, r, authed, user.Username)
}
func parseGitPath(path string) (owner, repo, rest string, ok bool) {
path = strings.TrimPrefix(path, "/")
idx := strings.Index(path, ".git")
if idx < 0 {
return "", "", "", false
}
repoPart := path[:idx]
rest = path[idx+len(".git"):]
parts := strings.Split(repoPart, "/")
if len(parts) != 2 {
return "", "", "", false
}
return parts[0], parts[1], rest, true
}
func gitService(r *http.Request) string {
if svc := r.URL.Query().Get("service"); svc != "" {
return svc
}
if strings.HasSuffix(r.URL.Path, "/git-receive-pack") {
return "git-receive-pack"
}
return "git-upload-pack"
}
func (s *Server) runGitHTTPBackend(w http.ResponseWriter, r *http.Request, authed bool, username string) {
root, _ := filepath.Abs(filepath.Join(s.storage, "repos"))
env := append(os.Environ(),
"GIT_PROJECT_ROOT="+root,
"GIT_HTTP_EXPORT_ALL=1",
"PATH_INFO="+r.URL.Path,
"REQUEST_METHOD="+r.Method,
"QUERY_STRING="+r.URL.RawQuery,
"REMOTE_ADDR="+r.RemoteAddr,
)
if ct := r.Header.Get("Content-Type"); ct != "" {
env = append(env, "CONTENT_TYPE="+ct)
}
if r.ContentLength >= 0 {
env = append(env, fmt.Sprintf("CONTENT_LENGTH=%d", r.ContentLength))
}
if authed {
env = append(env, "REMOTE_USER="+username, "AUTH_TYPE=Basic")
}
cmd := exec.Command("git", "http-backend")
cmd.Env = env
cmd.Stdin = r.Body
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
writeError(w, http.StatusInternalServerError, strings.TrimSpace(errBuf.String()))
return
}
writeCGIResponse(w, out.Bytes())
}
func writeCGIResponse(w http.ResponseWriter, data []byte) {
sep := []byte("\r\n\r\n")
idx := bytes.Index(data, sep)
if idx < 0 {
sep = []byte("\n\n")
idx = bytes.Index(data, sep)
}
if idx < 0 {
_, _ = w.Write(data)
return
}
headers := string(data[:idx])
body := data[idx+len(sep):]
status := http.StatusOK
for _, line := range strings.Split(headers, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if strings.EqualFold(k, "Status") {
fields := strings.Fields(v)
if len(fields) > 0 {
if n, err := strconv.Atoi(fields[0]); err == nil {
status = n
}
}
continue
}
w.Header().Add(k, v)
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
+31
View File
@@ -0,0 +1,31 @@
package app
import (
"net/http"
"net/url"
"testing"
)
func TestParseGitPath(t *testing.T) {
owner, repo, rest, ok := parseGitPath("/Alice/Demo.git/info/refs")
if !ok {
t.Fatal("expected path to parse")
}
if owner != "Alice" || repo != "Demo" || rest != "/info/refs" {
t.Fatalf("unexpected parse: %s %s %s", owner, repo, rest)
}
if _, _, _, ok := parseGitPath("/bad/path"); ok {
t.Fatal("expected invalid path")
}
}
func TestGitService(t *testing.T) {
req := &http.Request{Method: http.MethodGet, URL: &url.URL{RawQuery: "service=git-upload-pack"}}
if got := gitService(req); got != "git-upload-pack" {
t.Fatalf("unexpected upload-pack service: %q", got)
}
req = &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/alice/demo.git/git-receive-pack"}}
if got := gitService(req); got != "git-receive-pack" {
t.Fatalf("unexpected receive-pack service: %q", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package app
import "testing"
func TestSplitOwnerRepo(t *testing.T) {
owner, repo, err := splitOwnerRepo("Alice/Demo")
if err != nil {
t.Fatal(err)
}
if owner != "alice" || repo != "demo" {
t.Fatalf("unexpected split: %s/%s", owner, repo)
}
if _, _, err := splitOwnerRepo("bad"); err == nil {
t.Fatal("expected invalid repo ref to fail")
}
}
func TestSplitRepoBranch(t *testing.T) {
owner, repo, branch, err := splitRepoBranch("Alice/Demo:feature/x")
if err != nil {
t.Fatal(err)
}
if owner != "alice" || repo != "demo" || branch != "feature/x" {
t.Fatalf("unexpected split: %s/%s:%s", owner, repo, branch)
}
if _, _, _, err := splitRepoBranch("alice/demo"); err == nil {
t.Fatal("expected missing branch to fail")
}
}
func TestParseRepoCreateArgs(t *testing.T) {
name, visibility, description, jsonOut, err := parseRepoCreateArgs([]string{"demo", "--public", "--description", "hello", "--json"})
if err != nil {
t.Fatal(err)
}
if name != "demo" || visibility != "public" || description != "hello" || !jsonOut {
t.Fatalf("unexpected parse result: %q %q %q %v", name, visibility, description, jsonOut)
}
if _, _, _, _, err := parseRepoCreateArgs([]string{"demo"}); err == nil {
t.Fatal("expected missing visibility to fail")
}
}
func TestParseRepoPublishArgs(t *testing.T) {
name, visibility, description, remote, branch, err := parseRepoPublishArgs([]string{"demo", "--private", "--description=hello", "--remote", "upstream", "--branch", "trunk"})
if err != nil {
t.Fatal(err)
}
if name != "demo" || visibility != "private" || description != "hello" || remote != "upstream" || branch != "trunk" {
t.Fatalf("unexpected parse result: %q %q %q %q %q", name, visibility, description, remote, branch)
}
}
func TestRemoveBoolFlag(t *testing.T) {
args := []string{"repo", "--json", "extra"}
if !removeBoolFlag(&args, "--json") {
t.Fatal("expected flag to be removed")
}
if len(args) != 2 || args[0] != "repo" || args[1] != "extra" {
t.Fatalf("unexpected args: %#v", args)
}
}
+390
View File
@@ -0,0 +1,390 @@
package app
import (
"database/sql"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// ---------------- Pull request API ----------------
func (s *Server) handlePRCreate(w http.ResponseWriter, r *http.Request, targetOwner, targetName string) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
target, err := s.loadRepo(targetOwner, targetName)
if err != nil {
writeError(w, http.StatusNotFound, "target repository not found")
return
}
var in struct {
SourceOwner string `json:"source_owner"`
SourceRepo string `json:"source_repo"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
Title string `json:"title"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &in) {
return
}
in.SourceOwner = strings.ToLower(strings.TrimSpace(in.SourceOwner))
in.SourceRepo = strings.ToLower(strings.TrimSpace(in.SourceRepo))
in.SourceBranch = strings.TrimSpace(in.SourceBranch)
in.TargetBranch = strings.TrimSpace(in.TargetBranch)
in.Title = strings.TrimSpace(in.Title)
if in.SourceOwner == "" {
in.SourceOwner = target.Owner
}
if in.SourceRepo == "" {
in.SourceRepo = target.Name
}
if in.Title == "" || !branchRE.MatchString(in.SourceBranch) || !branchRE.MatchString(in.TargetBranch) {
writeError(w, http.StatusBadRequest, "title and valid source/target branches are required")
return
}
source, err := s.loadRepo(in.SourceOwner, in.SourceRepo)
if err != nil {
writeError(w, http.StatusNotFound, "source repository not found")
return
}
if source.ID == target.ID {
if target.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "same-repository PRs require repository ownership")
return
}
} else {
if source.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "source repository must be owned by you")
return
}
if target.Visibility != "public" && target.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "target repository is private")
return
}
}
if !gitBranchExists(s.repoPath(source.Owner, source.Name), in.SourceBranch) {
writeError(w, http.StatusBadRequest, "source branch does not exist")
return
}
if !gitBranchExists(s.repoPath(target.Owner, target.Name), in.TargetBranch) {
writeError(w, http.StatusBadRequest, "target branch does not exist")
return
}
var number int
_ = s.db.QueryRow(`SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE target_repository_id = ?`, target.ID).Scan(&number)
res, err := s.db.Exec(`INSERT INTO pull_requests (target_repository_id, number, author_user_id, source_repository_id, source_branch, target_branch, title, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, target.ID, number, user.ID, source.ID, in.SourceBranch, in.TargetBranch, in.Title, in.Description)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
id, _ := res.LastInsertId()
pr, _ := s.loadPR(target.ID, number)
pr.ID = id
writeJSON(w, http.StatusCreated, pr)
}
func (s *Server) handlePRList(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, err := s.loadRepo(owner, name)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
user, authed := s.optionalBearerUser(r)
if !s.canReadRepo(repo, user, authed) {
writeError(w, http.StatusNotFound, "repository not found")
return
}
rows, err := s.db.Query(prSelectSQL()+` WHERE pr.target_repository_id = ? ORDER BY pr.number DESC`, repo.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
prs, err := scanPRs(rows)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, prs)
}
func (s *Server) handlePRView(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
repo, err := s.loadRepo(owner, name)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
user, authed := s.optionalBearerUser(r)
if !s.canReadRepo(repo, user, authed) {
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
}
writeJSON(w, http.StatusOK, pr)
}
func (s *Server) handlePRClose(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 {
writeError(w, http.StatusNotFound, "repository not found")
return
}
if repo.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "only target owner can close pull requests")
return
}
res, err := s.db.Exec(`UPDATE pull_requests SET status = 'closed', closed_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ? AND status = 'open'`, repo.ID, number)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
writeError(w, http.StatusConflict, "pull request is not open")
return
}
pr, _ := s.loadPR(repo.ID, number)
writeJSON(w, http.StatusOK, pr)
}
func (s *Server) handlePRMerge(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
target, err := s.loadRepo(owner, name)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
if target.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "only target owner can merge pull requests")
return
}
pr, err := s.loadPR(target.ID, number)
if err != nil || pr.Status != "open" {
writeError(w, http.StatusConflict, "pull request is not open")
return
}
if err := s.mergePR(pr); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
_, err = s.db.Exec(`UPDATE pull_requests SET status = 'merged', merged_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ?`, target.ID, number)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pr, _ = s.loadPR(target.ID, number)
writeJSON(w, http.StatusOK, pr)
}
func prSelectSQL() string {
return `SELECT pr.id, pr.number, pr.target_repository_id, pr.source_repository_id, pr.author_user_id,
au.username, su.username, sr.name, pr.source_branch, tu.username, tr.name, pr.target_branch,
pr.title, pr.description, pr.status, pr.created_at, pr.updated_at, pr.closed_at, pr.merged_at
FROM pull_requests pr
JOIN users au ON au.id = pr.author_user_id
JOIN repositories sr ON sr.id = pr.source_repository_id
JOIN users su ON su.id = sr.owner_user_id
JOIN repositories tr ON tr.id = pr.target_repository_id
JOIN users tu ON tu.id = tr.owner_user_id`
}
func (s *Server) loadPR(targetRepoID int64, number int) (PullRequest, error) {
row := s.db.QueryRow(prSelectSQL()+` WHERE pr.target_repository_id = ? AND pr.number = ?`, targetRepoID, number)
prs, err := scanOnePR(row)
return prs, err
}
type scanner interface{ Scan(dest ...any) error }
func scanOnePR(row scanner) (PullRequest, error) {
var pr PullRequest
var closedAt, mergedAt sql.NullTime
err := row.Scan(&pr.ID, &pr.Number, &pr.TargetRepositoryID, &pr.SourceRepositoryID, &pr.AuthorUserID,
&pr.Author, &pr.SourceOwner, &pr.SourceRepo, &pr.SourceBranch, &pr.TargetOwner, &pr.TargetRepo, &pr.TargetBranch,
&pr.Title, &pr.Description, &pr.Status, &pr.CreatedAt, &pr.UpdatedAt, &closedAt, &mergedAt)
if closedAt.Valid {
pr.ClosedAt = &closedAt.Time
}
if mergedAt.Valid {
pr.MergedAt = &mergedAt.Time
}
return pr, err
}
func scanPRs(rows *sql.Rows) ([]PullRequest, error) {
var prs []PullRequest
for rows.Next() {
pr, err := scanOnePR(rows)
if err != nil {
return nil, err
}
prs = append(prs, pr)
}
return prs, rows.Err()
}
func (s *Server) mergePR(pr PullRequest) error {
targetPath := s.repoPath(pr.TargetOwner, pr.TargetRepo)
sourcePath := s.repoPath(pr.SourceOwner, pr.SourceRepo)
tmp, err := os.MkdirTemp("", "gitocean-merge-*")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
work := filepath.Join(tmp, "work")
if err := runGit("", "clone", targetPath, work); err != nil {
return err
}
if err := runGit(work, "config", "user.name", "gitocean"); err != nil {
return err
}
if err := runGit(work, "config", "user.email", "gitocean@localhost"); err != nil {
return err
}
if err := runGit(work, "checkout", pr.TargetBranch); err != nil {
return err
}
if err := runGit(work, "remote", "add", "source", sourcePath); err != nil {
return err
}
if err := runGit(work, "fetch", "source", pr.SourceBranch); err != nil {
return err
}
msg := fmt.Sprintf("Merge pull request #%d from %s/%s:%s", pr.Number, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch)
if err := runGit(work, "merge", "--no-ff", "FETCH_HEAD", "-m", msg); err != nil {
return fmt.Errorf("merge failed, likely due to conflicts: %w", err)
}
if err := runGit(work, "push", "origin", "HEAD:"+pr.TargetBranch); err != nil {
return err
}
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()})
}
+431
View File
@@ -0,0 +1,431 @@
package app
import (
"database/sql"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ---------------- Repository API ----------------
func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
var in struct {
Name string `json:"name"`
Visibility string `json:"visibility"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &in) {
return
}
name := strings.ToLower(strings.TrimSpace(in.Name))
visibility := strings.ToLower(strings.TrimSpace(in.Visibility))
if !repoNameRE.MatchString(name) || isReservedName(name) {
writeError(w, http.StatusBadRequest, "invalid repository name")
return
}
if visibility != "public" && visibility != "private" {
writeError(w, http.StatusBadRequest, "visibility must be public or private")
return
}
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
}
repoID, _ := res.LastInsertId()
path := s.repoPath(user.Username, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := gitInitBare(path); err != nil {
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
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) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
scope := r.URL.Query().Get("scope")
if scope == "" {
scope = "all"
}
user, authed := s.optionalBearerUser(r)
var rows *sql.Rows
var err error
like := "%" + q + "%"
if scope == "mine" {
if !authed {
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, 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, 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 = ? 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, 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)
}
} else {
writeError(w, http.StatusBadRequest, "scope must be all or mine")
return
}
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)
}
func (s *Server) handleRepoGet(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, err := s.loadRepo(owner, name)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
user, authed := s.optionalBearerUser(r)
if !s.canReadRepo(repo, user, authed) {
writeError(w, http.StatusNotFound, "repository not found")
return
}
writeJSON(w, http.StatusOK, repo)
}
func (s *Server) handleRepoDelete(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 delete this repository")
return
}
force := r.URL.Query().Get("force") == "true" || r.URL.Query().Get("force") == "1"
var openCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM pull_requests WHERE status = 'open' AND (target_repository_id = ? OR source_repository_id = ?)`, repo.ID, repo.ID).Scan(&openCount)
if openCount > 0 && !force {
writeError(w, http.StatusConflict, "repository has open pull requests; use force=true")
return
}
if force {
_, _ = s.db.Exec(`DELETE FROM pull_requests WHERE target_repository_id = ? OR source_repository_id = ?`, repo.ID, repo.ID)
}
_, err = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repo.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = os.RemoveAll(s.repoPath(owner, name))
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, name string) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
src, err := s.loadRepo(owner, name)
if err != nil {
writeError(w, http.StatusNotFound, "repository not found")
return
}
if src.Visibility == "private" && src.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "cannot fork this private repository")
return
}
var in struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
newName := strings.ToLower(strings.TrimSpace(in.Name))
if newName == "" {
newName = src.Name
}
if !repoNameRE.MatchString(newName) || isReservedName(newName) {
writeError(w, http.StatusBadRequest, "invalid repository name")
return
}
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
}
newID, _ := res.LastInsertId()
dstPath := s.repoPath(user.Username, newName)
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cmd := exec.Command("git", "clone", "--bare", s.repoPath(src.Owner, src.Name), dstPath)
if out, err := cmd.CombinedOutput(); err != nil {
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID)
_ = os.RemoveAll(dstPath)
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, 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, 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, &repo.Description, &repo.DefaultBranch, &repo.Archived, &fork, &repo.CreatedAt, &repo.UpdatedAt)
if fork.Valid {
repo.ForkedFromID = &fork.Int64
}
return repo, err
}
func scanRepos(rows *sql.Rows) ([]Repository, error) {
var repos []Repository
for rows.Next() {
var repo Repository
var fork sql.NullInt64
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 {
repo.ForkedFromID = &fork.Int64
}
repos = append(repos, repo)
}
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})
}
+132
View File
@@ -0,0 +1,132 @@
package app
import (
"net/http"
"strconv"
"strings"
)
// ---------------- HTTP routing ----------------
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
s.handleAPI(w, r)
return
}
if strings.Contains(r.URL.Path, ".git") {
s.handleGitHTTP(w, r)
return
}
writeError(w, http.StatusNotFound, "not found")
}
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case r.Method == http.MethodPost && path == "/register":
s.handleRegister(w, r)
case r.Method == http.MethodPost && path == "/login":
s.handleLogin(w, r)
case r.Method == http.MethodPost && path == "/logout":
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":
s.handleRepoSearch(w, r)
case strings.HasPrefix(path, "/repos/"):
s.handleRepoSubroute(w, r, strings.TrimPrefix(path, "/repos/"))
default:
writeError(w, http.StatusNotFound, "not found")
}
}
func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest string) {
parts := strings.Split(rest, "/")
if len(parts) < 2 {
writeError(w, http.StatusNotFound, "not found")
return
}
owner, repo := parts[0], parts[1]
if len(parts) == 2 {
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:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
return
}
if len(parts) == 3 && parts[2] == "fork" && r.Method == http.MethodPost {
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 {
case http.MethodGet:
s.handlePRList(w, r, owner, repo)
case http.MethodPost:
s.handlePRCreate(w, r, owner, repo)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
return
}
if len(parts) >= 4 {
n, err := strconv.Atoi(parts[3])
if err != nil {
writeError(w, http.StatusBadRequest, "invalid pull request number")
return
}
if len(parts) == 4 && r.Method == http.MethodGet {
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
}
if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "merge" {
s.handlePRMerge(w, r, owner, repo, n)
return
}
}
}
writeError(w, http.StatusNotFound, "not found")
}
+178
View File
@@ -0,0 +1,178 @@
package app
import (
"database/sql"
"errors"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"gitocean/internal/dbutil"
)
// ---------------- Server boot and migrations ----------------
func runServer(args []string) error {
fs := flag.NewFlagSet("server", flag.ExitOnError)
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", "", "storage directory")
publicURL := fs.String("public-url", "", "public server URL")
if err := fs.Parse(args); err != nil {
return err
}
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(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
}
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) {
return dbutil.OpenMySQLAndCreateDatabaseIfMissing(dsn)
}
func quoteMySQLIdentifier(name string) string {
return dbutil.QuoteMySQLIdentifier(name)
}
func migrate(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
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 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_auth_tokens_user_id (user_id),
INDEX idx_auth_tokens_expires_at (expires_at),
CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS repositories (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
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,
UNIQUE KEY uniq_owner_name (owner_user_id, name),
INDEX idx_repositories_name (name),
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,
number INT NOT NULL,
author_user_id BIGINT NOT NULL,
source_repository_id BIGINT NOT NULL,
source_branch VARCHAR(200) NOT NULL,
target_branch VARCHAR(200) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
status ENUM('open','closed','merged') NOT NULL DEFAULT 'open',
closed_at TIMESTAMP NULL,
merged_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_target_number (target_repository_id, number),
INDEX idx_pr_target (target_repository_id),
INDEX idx_pr_source (source_repository_id),
CONSTRAINT fk_pr_target FOREIGN KEY (target_repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
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
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"net/http"
"path/filepath"
"strings"
"gitocean/internal/gitutil"
"gitocean/internal/httputil"
"gitocean/internal/validate"
)
// ---------------- Shared helpers ----------------
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
return httputil.DecodeJSON(w, r, out)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
httputil.WriteJSON(w, status, v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
httputil.WriteError(w, status, msg)
}
func isReservedName(s string) bool {
return validate.ReservedName(s)
}
func (s *Server) repoPath(owner, name string) string {
return filepath.Join(s.storage, "repos", strings.ToLower(owner), strings.ToLower(name)+".git")
}
func gitInitBare(path string) error {
return gitutil.InitBare(path)
}
func gitBranchExists(repoPath, branch string) bool {
return gitutil.BranchExists(repoPath, branch)
}
func runGit(dir string, args ...string) error {
return gitutil.Run(dir, args...)
}
+117
View File
@@ -0,0 +1,117 @@
package backup
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-sql-driver/mysql"
"gitocean/internal/model"
)
func Create(cfg model.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 Restore(cfg model.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())
})
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"gitocean/internal/model"
)
const (
DefaultServerURL = "http://localhost:8080"
DefaultStorage = "storage"
)
func EnvServer() string {
if v := os.Getenv("GITOOCEAN_SERVER"); v != "" {
return strings.TrimRight(v, "/")
}
if cfg, err := LoadServer(DefaultServerConfigPath()); err == nil && cfg.PublicURL != "" {
return strings.TrimRight(cfg.PublicURL, "/")
}
return DefaultServerURL
}
func ClientConfigPath() string {
if v := os.Getenv("GITOOCEAN_CONFIG"); v != "" {
return v
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "gitocean", "config.json")
}
func LoadClient() (model.Config, error) {
b, err := os.ReadFile(ClientConfigPath())
if err != nil {
return model.Config{}, errors.New("not logged in; run gitocean login")
}
var cfg model.Config
if err := json.Unmarshal(b, &cfg); err != nil {
return model.Config{}, err
}
if cfg.Server == "" || cfg.Token == "" {
return model.Config{}, errors.New("invalid config; run gitocean login")
}
return cfg, nil
}
func SaveClient(cfg model.Config) error {
path := ClientConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
b, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(path, b, 0600)
}
func DefaultServerConfigPath() string {
if v := os.Getenv("GITOOCEAN_SERVER_CONFIG"); v != "" {
return v
}
return filepath.Join(DefaultStorage, "config.json")
}
func LoadServer(path string) (model.ServerConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return model.ServerConfig{}, err
}
var cfg model.ServerConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return model.ServerConfig{}, err
}
return cfg, nil
}
func SaveServer(path string, cfg model.ServerConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
b, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(path, b, 0600)
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"path/filepath"
"testing"
"time"
"gitocean/internal/model"
)
func TestClientConfigRoundTrip(t *testing.T) {
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
cfg := model.Config{Server: "http://example.test", Username: "alice", Token: "tok", ExpiresAt: time.Now().UTC()}
if err := SaveClient(cfg); err != nil {
t.Fatal(err)
}
got, err := LoadClient()
if err != nil {
t.Fatal(err)
}
if got.Server != cfg.Server || got.Username != cfg.Username || got.Token != cfg.Token {
t.Fatalf("unexpected config: %#v", got)
}
}
func TestServerConfigRoundTripAndEnvServer(t *testing.T) {
path := filepath.Join(t.TempDir(), "server.json")
t.Setenv("GITOOCEAN_SERVER_CONFIG", path)
t.Setenv("GITOOCEAN_SERVER", "")
cfg := model.ServerConfig{Addr: ":8080", PublicURL: "http://localhost:8080/", Storage: "storage", MySQLDSN: "u:p@tcp(localhost:3306)/gitocean"}
if err := SaveServer(path, cfg); err != nil {
t.Fatal(err)
}
got, err := LoadServer(path)
if err != nil {
t.Fatal(err)
}
if got.PublicURL != cfg.PublicURL || got.MySQLDSN != cfg.MySQLDSN {
t.Fatalf("unexpected server config: %#v", got)
}
if server := EnvServer(); server != "http://localhost:8080" {
t.Fatalf("unexpected env server: %q", server)
}
}
+68
View File
@@ -0,0 +1,68 @@
package dbutil
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/go-sql-driver/mysql"
)
func OpenMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err == nil {
return db, nil
} else {
_ = db.Close()
var mysqlErr *mysql.MySQLError
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1049 {
return nil, err
}
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, err
}
if cfg.DBName == "" {
return nil, errors.New("DSN does not include a database name")
}
databaseName := cfg.DBName
cfg.DBName = ""
adminDB, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return nil, err
}
defer adminDB.Close()
if err := adminDB.Ping(); err != nil {
return nil, err
}
if _, err := adminDB.Exec("CREATE DATABASE IF NOT EXISTS " + QuoteMySQLIdentifier(databaseName) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
return nil, err
}
fmt.Printf("created database %q\n", databaseName)
db, err = sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func QuoteMySQLIdentifier(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func IsDuplicateColumnError(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
}
+10
View File
@@ -0,0 +1,10 @@
package dbutil
import "testing"
func TestQuoteMySQLIdentifier(t *testing.T) {
got := QuoteMySQLIdentifier("git`ocean")
if got != "`git``ocean`" {
t.Fatalf("unexpected quote: %q", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
package gitutil
import (
"fmt"
"os/exec"
"strings"
"time"
"gitocean/internal/model"
"gitocean/internal/validate"
)
func Run(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return nil
}
func InitBare(path string) error {
if err := Run("", "init", "--bare", path); err != nil {
return err
}
return Run("", "--git-dir", path, "symbolic-ref", "HEAD", "refs/heads/main")
}
func BranchExists(repoPath, branch string) bool {
if !validate.Branch(branch) {
return false
}
cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", "refs/heads/"+branch)
return cmd.Run() == nil
}
func Refs(repoPath, prefix string) ([]model.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 []model.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, model.RefInfo{Name: parts[0], Commit: parts[1], Date: date, Message: parts[3]})
}
return refs, nil
}
+27
View File
@@ -0,0 +1,27 @@
package httputil
import (
"encoding/json"
"net/http"
)
func DecodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
WriteError(w, http.StatusBadRequest, "invalid json: "+err.Error())
return false
}
return true
}
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
+90
View File
@@ -0,0 +1,90 @@
package model
import "time"
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"`
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 {
ID int64 `json:"id"`
Number int `json:"number"`
TargetRepositoryID int64 `json:"-"`
SourceRepositoryID int64 `json:"-"`
AuthorUserID int64 `json:"-"`
Author string `json:"author"`
SourceOwner string `json:"source_owner"`
SourceRepo string `json:"source_repo"`
SourceBranch string `json:"source_branch"`
TargetOwner string `json:"target_owner"`
TargetRepo string `json:"target_repo"`
TargetBranch string `json:"target_branch"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
MergedAt *time.Time `json:"merged_at,omitempty"`
}
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 {
Server string `json:"server"`
Username string `json:"username"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
+22
View File
@@ -0,0 +1,22 @@
package validate
import "regexp"
var (
UsernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
RepoNameRE = regexp.MustCompile(`^[a-z0-9._-]{1,100}$`)
BranchRE = regexp.MustCompile(`^[A-Za-z0-9._/\-]{1,200}$`)
)
func Username(s string) bool { return UsernameRE.MatchString(s) }
func RepoName(s string) bool { return RepoNameRE.MatchString(s) }
func Branch(s string) bool { return BranchRE.MatchString(s) }
func ReservedName(s string) bool {
switch s {
case "api", "admin", "storage", "git", "repos", ".", "..":
return true
default:
return false
}
}
+33
View File
@@ -0,0 +1,33 @@
package validate
import "testing"
func TestNameValidation(t *testing.T) {
if !Username("alice_1") {
t.Fatal("expected username to be valid")
}
if Username("Alice") || Username("ab") || Username("alice.example") {
t.Fatal("expected invalid usernames to be rejected")
}
if !RepoName("demo.repo_1") {
t.Fatal("expected repo name to be valid")
}
if RepoName("Demo") || RepoName("bad/name") || RepoName("") {
t.Fatal("expected invalid repo names to be rejected")
}
if !Branch("feature/demo-1") {
t.Fatal("expected branch to be valid")
}
if Branch("bad branch") || Branch("") {
t.Fatal("expected invalid branches to be rejected")
}
}
func TestReservedName(t *testing.T) {
if !ReservedName("api") || !ReservedName("..") {
t.Fatal("expected reserved names")
}
if ReservedName("alice") {
t.Fatal("did not expect normal name to be reserved")
}
}
+337
View File
@@ -0,0 +1,337 @@
# Gitocean Phase 1 Plan: MVP
Phase 1 focuses on building the minimum viable Git hosting platform: CLI + API only, MySQL metadata, repositories stored on disk, HTTP Smart Git transport, public/private repositories, search, forks, and pull requests.
This phase intentionally avoids a web UI, SSH Git transport, organizations, teams, CI/CD, and enterprise features. The goal is a working personal Git hosting foundation.
## Goals
- Build a Go-based Git repository hosting platform.
- Store bare repositories under `storage/repos/{owner}/{repo}.git`.
- Use MySQL for metadata.
- Support HTTP Smart Git clone/fetch/push.
- Provide CLI-based registration, login, repository management, search, forks, and pull requests.
- Support public and private repositories.
- Use temporary 7-day API/Git auth tokens.
- Keep permissions simple: owner-only writes and private access in MVP.
---
## 1. Product decisions
### Platform shape
- Gitocean is a CLI + API Git hosting system.
- No Web UI in Phase 1.
- No SSH Git transport in Phase 1.
- Repositories are stored as bare Git repositories on disk.
- Metadata is stored in MySQL.
### Registration and login
- Registration is open.
- Users register with:
- email
- username
- password
- Users can log in with either:
- email + password
- username + password
- Login returns a temporary token that expires after 7 days.
- The CLI stores the token locally for API calls.
- The CLI can approve the token into Git's credential helper for HTTP Git operations.
### Naming defaults
- CLI binary name: `gitocean`.
- Default branch: `main`.
- Repository names allow lowercase letters, numbers, `.`, `_`, and `-`.
- Usernames allow lowercase letters, numbers, `_`, and `-`.
- Pull request numbers are scoped per target repository.
---
## 2. Permissions
### Repository visibility
Public repositories:
- Anyone can clone/fetch.
- Only the owner can push.
- Only the owner can delete or manage settings.
Private repositories:
- Only the owner can clone/fetch.
- Only the owner can push.
- Only the owner can delete or manage settings.
### Writes
- Phase 1 uses owner-only writes.
- No collaborators, organizations, teams, or fine-grained permissions yet.
- Outside collaboration happens through pull requests against public repositories.
---
## 3. Storage layout
Repositories live under:
```text
storage/repos/{owner}/{repo}.git
```
Example:
```text
storage/
repos/
alice/
demo.git/
bob/
project.git/
```
Each repository directory is a bare Git repository created with `git init --bare`.
---
## 4. MySQL schema
Core tables:
### `users`
- `id`
- `email`
- `username`
- `password_hash`
- `created_at`
### `auth_tokens`
- `id`
- `user_id`
- `token_hash`
- `expires_at`
- `revoked_at`
- `created_at`
### `repositories`
- `id`
- `owner_user_id`
- `name`
- `visibility`: `public` or `private`
- `forked_from_repository_id` nullable
- `created_at`
- `updated_at`
### `pull_requests`
- `id`
- `target_repository_id`
- `number`
- `author_user_id`
- `source_repository_id`
- `source_branch`
- `target_branch`
- `title`
- `description`
- `status`: `open`, `closed`, or `merged`
- `closed_at` nullable
- `merged_at` nullable
- `created_at`
- `updated_at`
---
## 5. API routes
### Auth
```http
POST /api/register
POST /api/login
POST /api/logout
GET /api/me
```
### Repositories
```http
POST /api/repos
GET /api/repos/search?q=demo&scope=all
GET /api/repos/search?q=demo&scope=mine
GET /api/repos/{owner}/{repo}
DELETE /api/repos/{owner}/{repo}?force=false
POST /api/repos/{owner}/{repo}/fork
```
### Pull requests
```http
POST /api/repos/{owner}/{repo}/pulls
GET /api/repos/{owner}/{repo}/pulls
GET /api/repos/{owner}/{repo}/pulls/{number}
POST /api/repos/{owner}/{repo}/pulls/{number}/close
POST /api/repos/{owner}/{repo}/pulls/{number}/merge
```
### Git HTTP
```http
/{owner}/{repo}.git/info/refs
/{owner}/{repo}.git/git-upload-pack
/{owner}/{repo}.git/git-receive-pack
```
---
## 6. CLI commands
### Server
```bash
gitocean server --addr :8080 --dsn 'user:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true' --storage storage
```
### Auth
```bash
gitocean register
gitocean login
gitocean logout
gitocean whoami
```
### Repositories
```bash
gitocean repo create NAME --public
gitocean repo create NAME --private
gitocean repo delete OWNER/NAME [--force]
gitocean repo search QUERY --all
gitocean repo search QUERY --mine
gitocean repo fork OWNER/NAME [--name NEW_NAME]
```
### Clone
```bash
gitocean clone OWNER/NAME
```
### Pull requests
```bash
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 close OWNER/REPO NUMBER
gitocean pr merge OWNER/REPO NUMBER
```
---
## 7. Git HTTP behavior
Use Git's built-in Smart HTTP backend:
```bash
git http-backend
```
Required behavior:
- Public clone/fetch works without auth.
- Private clone/fetch requires owner auth.
- Push always requires auth.
- Push is owner-only.
- Git username is the account username.
- Git password is the 7-day token.
---
## 8. Fork behavior
- Logged-in users can fork public repositories.
- Private repositories can only be forked by the owner in Phase 1.
- Forks are copied as bare repositories under the new owner's storage path.
- Fork metadata stores `forked_from_repository_id`.
---
## 9. Pull request rules
### Same-repository PRs
- Source and target repository are the same.
- Only the repository owner can create same-repo PRs.
- Source and target branches must both exist.
### Cross-repository PRs
- Source repository branch points to target repository branch.
- Source repository must be owned by the PR author.
- Target repository must be public, unless the author is also the target owner.
- Source and target branches must both exist.
- Only the target repository owner can close or merge the PR.
---
## 10. Merge behavior
Phase 1 merge mode is a normal merge commit:
1. Clone the target repo into a temporary worktree.
2. Checkout the target branch.
3. Add/fetch the source repo branch.
4. Run `git merge --no-ff FETCH_HEAD`.
5. Push the result back to the target branch.
6. Mark the PR as merged.
If conflicts occur:
- Return an error.
- Keep the PR open.
- Do not modify PR status.
---
## 11. Implementation milestones
1. Project scaffold and build setup.
2. MySQL migrations.
3. User registration, login, logout, and token auth.
4. Repository create, delete, detail, and search.
5. HTTP Smart Git integration using `git http-backend`.
6. CLI auth and repository commands.
7. Fork support.
8. Pull request create/list/view/close.
9. Pull request merge.
10. Tests, hardening, and UX polish.
---
## Phase 1 completion criteria
Phase 1 is complete when:
- The server starts with MySQL metadata storage.
- Missing MySQL databases can be created automatically.
- Users can register, login, logout, and run `whoami`.
- Login tokens expire after 7 days.
- Users can create public/private repositories.
- Public repositories can be cloned/fetched without auth.
- Private repositories require owner auth.
- Owners can push over HTTP.
- Non-owners cannot push.
- Users can search visible repositories.
- Users can fork allowed repositories.
- Users can create, list, view, close, and merge pull requests.
- `go test ./...` passes.
- `go vet ./...` passes.
+646
View File
@@ -0,0 +1,646 @@
# Phase 3 Plan: Split the God File into an Organized Codebase
## Goal
Refactor the current monolithic `cmd/gitocean/main.go` into a maintainable, testable, multi-package Go codebase without changing user-facing behavior.
Phase 3 is primarily a structural refactor. The CLI commands, API routes, Git HTTP behavior, MySQL schema, storage layout, and auth rules should continue to work exactly as they do after Phase 2.
Phase 3 should also finish documenting and preserving the CLI UX direction from Phase 2: human-readable CLI output by default, with JSON available only as an explicit opt-in where useful. This does **not** mean removing JSON from the HTTP API; API requests and responses should remain JSON.
## Non-goals
- Do not add a Web UI.
- Do not add SSH Git transport.
- Do not replace MySQL.
- Do not redesign API routes unless a compatibility shim is kept.
- Do not introduce large frameworks.
- Do not implement new product features until the refactor is stable.
- Do not remove JSON from the HTTP API. Only CLI output should become human-readable by default.
## Current problem
`cmd/gitocean/main.go` currently contains everything:
- Type definitions
- CLI command routing and command implementations
- HTTP server bootstrapping
- API route dispatch
- Auth/token logic
- User/repository/PR/collaborator handlers
- Git HTTP backend integration
- MySQL migrations and DB helpers
- Config loading/saving
- Git helper functions
- Backup/restore logic
- Output formatting
- Generic HTTP JSON helpers
This makes the code hard to test, hard to extend, and risky to modify.
## Refactor principles
1. **No behavior changes first**
- Move code into packages with minimal edits.
- Keep route paths, JSON payloads, CLI command names, flags, and output stable unless explicitly noted.
2. **Small, reviewable steps**
- Move one domain at a time.
- Run `gofmt`, `go test ./...`, and `go vet ./...` after each major move.
3. **Human-readable CLI by default**
- During CLI extraction, audit commands that still print raw JSON.
- Convert default CLI output to concise human-readable text/tables.
- Keep `--json` as an explicit escape hatch for scripting where appropriate.
- Do not change API JSON payloads.
4. **Thin `main` package**
- `cmd/gitocean/main.go` should only call into an application package.
5. **Reusable packages**
- Shared utilities should live in `internal/` packages, not in `cmd/`.
6. **Domain boundaries over technical dumping grounds**
- Prefer packages like `repos`, `pulls`, `auth`, and `gitserver` over one huge `utils` package.
7. **Testable dependencies**
- Handlers and services should accept dependencies through structs.
- Avoid package-level global state except constants and regex validators.
## Target directory layout
```text
cmd/
gitocean/
main.go # Tiny entrypoint only
internal/
app/
app.go # Top-level CLI dispatch / application orchestration
usage.go # Help text
config/
client.go # CLI config: ~/.config/gitocean/config.json
server.go # Server config: storage/config.json
env.go # Environment defaults
db/
mysql.go # MySQL open/create database logic
migrate.go # Migrations
model/
user.go
repository.go
pull_request.go
collaborator.go
token.go
ref.go
httpapi/
server.go # Server struct and ServeHTTP
router.go # API route dispatch
json.go # decodeJSON/writeJSON/writeError
auth/
handlers.go # register/login/logout/me handlers
service.go # token generation, hashing, bearer/basic auth
password.go # password hashing helpers if needed
tokens/
handlers.go # token list/revoke/prune handlers
admin/
handlers.go # admin users/repos/storage/token routes
repos/
handlers.go # repo create/get/update/delete/search/fork
service.go # repo access checks and repo loading
collaborators.go # collaborator handlers and role logic
validate.go # repo/user name validation helpers if not shared
pulls/
handlers.go # PR create/list/view/close/merge/diff/comments
service.go # PR loading, scanning, merge/diff helpers
gitserver/
http.go # Smart HTTP route parsing and permissions
backend.go # git http-backend CGI bridge
gitutil/
git.go # runGit, gitInitBare, gitBranchExists, gitRefs
cli/
root.go # CLI command dispatch
auth.go # register/login/logout/whoami
repo.go # repo command dispatch
repo_create.go
repo_publish.go
repo_view.go
repo_collaborators.go
pr.go
token.go
admin.go
backup.go
output.go # CLI formatting helpers
parse.go # splitOwnerRepo, splitRepoBranch, flag parsing
prompt.go
api_client.go # CLI HTTP client helper
backup/
backup.go # createBackup/restoreBackup/mysqlCLIArgs/copyDir
validate/
names.go # username/repo/branch validation and reserved names
```
The exact final package names can change during implementation, but the direction should stay the same: small domain packages with clear responsibilities.
## Dependency direction
Recommended dependency flow:
```text
cmd/gitocean
-> internal/app
-> internal/cli
-> internal/httpapi
-> internal/config
-> internal/db
httpapi
-> auth, tokens, admin, repos, pulls, gitserver
-> model
repos/pulls/auth/etc.
-> model
-> gitutil where needed
-> validate where needed
cli
-> config
-> model
-> backup where needed
```
Avoid circular dependencies by keeping shared types in `internal/model` and shared helpers in targeted utility packages.
## Proposed milestones
### Milestone 1: Prepare shared model and validation packages
Move pure data/types and validators first.
Create:
- `internal/model`
- `internal/validate`
Move:
- `User`
- `Repository`
- `PullRequest`
- `ServerConfig`
- `RefInfo`
- `Collaborator`
- `PRComment`
- `TokenInfo`
- username/repo/branch regex validation
- `isReservedName`
Acceptance:
- `cmd/gitocean/main.go` still builds.
- All references use `model.User`, `model.Repository`, etc.
- `go test ./...` passes.
### Milestone 2: Extract config and generic helpers
Create:
- `internal/config`
- `internal/httpapi/json.go`
- `internal/gitutil`
Move:
- CLI config path/load/save
- server config path/load/save
- env server URL helper
- JSON request/response helpers where appropriate
- `gitInitBare`
- `gitBranchExists`
- `gitRefs`
- `runGit`
Acceptance:
- No behavior changes.
- CLI login still saves config.
- Server still reads config and env overrides.
- Git helper functions are reusable outside `main`.
### Milestone 3: Extract DB connection and migrations
Create:
- `internal/db`
Move:
- `openMySQLAndCreateDatabaseIfMissing`
- `quoteMySQLIdentifier`
- `migrate`
- `isDuplicateColumnError`
Acceptance:
- Server starts from empty DB.
- Missing database auto-create behavior still works.
- Migrations are isolated and testable.
### Milestone 4: Extract HTTP API server shell
Create:
- `internal/httpapi/server.go`
- `internal/httpapi/router.go`
Move:
- `Server` struct
- `ServeHTTP`
- top-level API route dispatch
- subroute dispatch helpers
Keep domain handler method bodies temporarily in `httpapi` if needed, then move them by domain in later milestones.
Acceptance:
- `runServer` constructs `httpapi.Server`.
- API route paths remain unchanged.
- Git HTTP routes still reach the backend.
### Milestone 5: Extract auth and token domains
Create:
- `internal/auth`
- `internal/tokens`
Move:
- register/login/logout/me handlers
- token creation/hash helpers
- bearer/basic auth helpers
- token list/revoke/prune handlers
- direct admin-user creation helper used by `init`
Acceptance:
- Registration still makes the first user admin.
- Login still returns 7-day tokens.
- CLI login/whoami/logout still work.
- Git basic auth still works with username/token.
### Milestone 6: Extract repository domain
Create:
- `internal/repos`
Move:
- repo create/get/update/delete/search/fork handlers
- repository load/scan helpers
- repo access helpers:
- `requireReadableRepo`
- `canReadRepo`
- `canWriteRepo`
- `collaboratorRole`
- collaborator list/add/remove handlers
Acceptance:
- Public/private visibility rules still work.
- Private collaborators can read when allowed.
- Write collaborators can push when allowed.
- Archived repositories still reject pushes.
- Repo CLI commands still work.
### Milestone 7: Extract pull request domain
Create:
- `internal/pulls`
Move:
- PR create/list/view/close/merge handlers
- PR comments handlers
- PR diff handler
- `prSelectSQL`
- `loadPR`
- `scanOnePR`
- `scanPRs`
- `mergePR`
- `prDiff`
Acceptance:
- Same-repo PRs still work.
- Cross-repo PRs still work.
- PR merge still uses Git commands correctly.
- PR diff/comments endpoints still work.
### Milestone 8: Extract Git Smart HTTP server
Create:
- `internal/gitserver`
Move:
- `handleGitHTTP`
- `parseGitPath`
- `gitService`
- `runGitHTTPBackend`
- `writeCGIResponse`
Design note:
`gitserver` should depend on a small permission interface instead of importing the entire repo handler package if possible:
```go
type RepoAccess interface {
LoadRepo(owner, name string) (model.Repository, error)
CanReadRepo(repo model.Repository, user model.User, authed bool) bool
CanWriteRepo(repo model.Repository, user model.User) bool
UserFromBasic(r *http.Request) (model.User, bool)
}
```
Acceptance:
- Public clone/fetch still works without auth.
- Private clone/fetch works for owner/collaborators only.
- Push requires auth.
- Archived repos reject pushes.
- Git HTTP backend response handling remains correct.
### Milestone 9: Extract CLI package
Create:
- `internal/cli`
Move:
- command dispatch
- `usage`
- `cliInit`
- auth CLI commands
- repo CLI commands
- PR CLI commands
- token/admin/backup CLI commands
- CLI API request helper
- output formatting helpers
- prompt helpers
- parsing helpers
- Git credential approval helper
Acceptance:
- `cmd/gitocean/main.go` is reduced to roughly:
```go
package main
import (
"fmt"
"os"
"gitocean/internal/app"
)
func main() {
if err := app.Run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
```
- Every existing CLI command still works.
- Help text is unchanged except for intentional wording cleanup.
### Milestone 10: Extract backup package
Create:
- `internal/backup`
Move:
- `createBackup`
- `restoreBackup`
- `mysqlCLIArgs`
- `copyDir`
Acceptance:
- `gitocean backup create FILE` still works.
- `gitocean backup restore FILE` still works.
- Backup logic is testable independently.
### Milestone 11: Add package-level tests
Add focused unit tests where possible.
Suggested tests:
- `internal/validate`
- username validation
- repo name validation
- branch validation
- reserved names
- `internal/cli`
- `splitOwnerRepo`
- `splitRepoBranch`
- `parseRepoCreateArgs`
- `parseRepoPublishArgs`
- boolean flag removal
- `internal/config`
- client config load/save round trip
- server config load/save round trip
- env override behavior
- `internal/gitserver`
- `parseGitPath`
- `gitService`
- CGI response parsing
- `internal/db`
- MySQL identifier quoting
Acceptance:
- `go test ./...` includes meaningful tests.
- Tests avoid requiring a live MySQL instance unless explicitly integration-tagged.
### Milestone 12: CLI output audit
After the CLI package has been extracted, audit every CLI command for output style.
Commands should default to human-readable output:
- Short success messages for create/update/delete actions.
- Tables for lists.
- Labeled fields for detail views.
- Helpful next-step commands where useful.
- Clear auth/session error messages.
JSON should be retained only as an explicit opt-in for scripting, for example:
```bash
gitocean repo view OWNER/REPO --json
gitocean repo search QUERY --json
gitocean pr view OWNER/REPO NUMBER --json
gitocean token list --json
```
Acceptance:
- No normal CLI command prints raw JSON by default.
- Commands that are useful in scripts have a documented `--json` option.
- HTTP API responses remain JSON and are not changed by this milestone.
### Milestone 13: Documentation cleanup
Update docs/plans after the refactor:
- Update `plans/PLAN.md` if architecture descriptions changed.
- Update `plans/PHASE_2_PLAN.md` if current layout references are stale.
- Add a short `README.md` if missing with:
- quickstart
- server setup
- CLI commands
- storage layout
- development workflow
Acceptance:
- New contributors can find where CLI, API, DB, Git HTTP, and models live.
## Suggested implementation order
Recommended commit sequence:
1. `Add Phase 3 refactor plan`
2. `Move shared models and validators`
3. `Extract config and Git utilities`
4. `Extract database setup and migrations`
5. `Extract HTTP API server shell`
6. `Extract auth and token handlers`
7. `Extract repository handlers`
8. `Extract pull request handlers`
9. `Extract Git HTTP backend`
10. `Extract CLI commands`
11. `Extract backup utilities`
12. `Audit CLI output and JSON escape hatches`
13. `Add package-level tests and documentation`
## Compatibility checklist
After each milestone, run:
```bash
gofmt -w .
go test ./...
go vet ./...
```
Before declaring Phase 3 complete, manually verify:
```bash
gitocean init
gitocean server --config storage/config.json
gitocean register
gitocean login
gitocean whoami
gitocean repo create demo --public
gitocean repo view USER/demo
gitocean repo branches USER/demo
gitocean repo tags USER/demo
gitocean repo search demo --all
gitocean clone USER/demo
gitocean repo publish demo2 --public
gitocean repo fork USER/demo
gitocean pr create --from USER/demo-fork:main --to USER/demo:main --title "Test PR"
gitocean pr list USER/demo
gitocean pr view USER/demo 1
gitocean pr diff USER/demo 1
gitocean pr comment USER/demo 1 "Looks good"
gitocean pr comments USER/demo 1
gitocean token list
gitocean admin users list
gitocean backup create backup.tar.gz
```
Also verify Git HTTP manually:
```bash
git clone http://localhost:8080/USER/demo.git
cd demo
echo test >> README.md
git add README.md
git commit -m "test push"
git push origin main
```
## Risks and mitigations
### Risk: Circular package dependencies
Mitigation:
- Put shared structs in `internal/model`.
- Put small interfaces between domains where needed.
- Keep HTTP response helpers separate from domain services.
### Risk: Refactor changes behavior accidentally
Mitigation:
- Move code first, improve code second.
- Keep commits small.
- Add tests for parsing/routing helpers early.
### Risk: Handlers still know too much about SQL
Mitigation:
- Phase 3 can keep SQL in domain packages.
- A later phase can introduce repository/store interfaces if needed.
### Risk: `utils` package becomes another god package
Mitigation:
- Only use utility packages for genuinely cross-domain helpers.
- Prefer domain packages.
## Phase 3 completion criteria
Phase 3 is complete when:
- `cmd/gitocean/main.go` is a thin entrypoint.
- No package contains unrelated CLI, API, DB, Git, and model code together.
- Domain packages have clear responsibilities.
- Existing CLI commands and API routes still work.
- CLI output is human-readable by default, with `--json` opt-in where useful.
- `go test ./...` passes.
- `go vet ./...` passes.
- Meaningful unit tests exist for parsing, validation, config, and Git HTTP helpers.