Refactor codebase for Phase 3
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user