phase 2 initial test

This commit is contained in:
2026-05-06 23:39:04 +02:00
parent 8592fc5d65
commit 57991e5406
17 changed files with 720 additions and 133 deletions
+17
View File
@@ -0,0 +1,17 @@
package handlers
import (
"encoding/json"
"net/http"
)
func jsonError(w http.ResponseWriter, message string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
+39 -8
View File
@@ -1,22 +1,53 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
type PipelineHandler struct{}
type PipelineHandler struct {
db *xorm.Engine
}
func NewPipelineHandler() *PipelineHandler {
return &PipelineHandler{}
func NewPipelineHandler(db *xorm.Engine) *PipelineHandler {
return &PipelineHandler{db: db}
}
func (h *PipelineHandler) List(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]any{})
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
repoID, ok := h.repoID(w, ownerName, repoName)
if !ok {
return
}
_ = repoID
// Pipeline records will be added in Phase 3 (CI integration).
// Return empty list so the client doesn't break.
jsonOK(w, []any{})
}
func (h *PipelineHandler) Get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
jsonError(w, "not implemented", http.StatusNotImplemented)
}
func (h *PipelineHandler) repoID(w http.ResponseWriter, ownerName, repoName string) (int64, bool) {
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
+152 -10
View File
@@ -3,31 +3,173 @@ package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type PRHandler struct{}
type PRHandler struct {
db *xorm.Engine
}
func NewPRHandler() *PRHandler {
return &PRHandler{}
func NewPRHandler(db *xorm.Engine) *PRHandler {
return &PRHandler{db: db}
}
func (h *PRHandler) List(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]any{})
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
sess := h.db.Where("repo_id = ?", repoID)
if status != "" {
sess = sess.And("status = ?", status)
}
var prs []models.PullRequest
if err := sess.Find(&prs); err != nil {
jsonError(w, "could not list pull requests", http.StatusInternalServerError)
return
}
if prs == nil {
prs = []models.PullRequest{}
}
jsonOK(w, prs)
}
func (h *PRHandler) Create(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return
}
authorID, _ := middleware.UserIDFromContext(r.Context())
var body struct {
Title string `json:"title"`
Body string `json:"body"`
SourceBranch string `json:"sourceBranch"`
TargetBranch string `json:"targetBranch"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Title == "" || body.SourceBranch == "" {
jsonError(w, "title and sourceBranch are required", http.StatusBadRequest)
return
}
if body.TargetBranch == "" {
body.TargetBranch = "main"
}
pr := &models.PullRequest{
RepoID: repoID,
AuthorID: authorID,
Title: body.Title,
Body: body.Body,
SourceBranch: body.SourceBranch,
TargetBranch: body.TargetBranch,
Status: models.PRStatusOpen,
}
if _, err := h.db.Insert(pr); err != nil {
jsonError(w, "could not create pull request", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
json.NewEncoder(w).Encode(pr)
}
func (h *PRHandler) Get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
jsonOK(w, pr)
}
func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "merged"})
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
if pr.Status != models.PRStatusOpen {
jsonError(w, "pull request is not open", http.StatusConflict)
return
}
pr.Status = models.PRStatusMerged
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not merge pull request", http.StatusInternalServerError)
return
}
jsonOK(w, pr)
}
func (h *PRHandler) Close(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
if pr.Status != models.PRStatusOpen {
jsonError(w, "pull request is not open", http.StatusConflict)
return
}
pr.Status = models.PRStatusClosed
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not close pull request", http.StatusInternalServerError)
return
}
jsonOK(w, pr)
}
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return nil, false
}
prIDStr := chi.URLParam(r, "prID")
prID, err := strconv.ParseInt(prIDStr, 10, 64)
if err != nil {
jsonError(w, "invalid pull request ID", http.StatusBadRequest)
return nil, false
}
var pr models.PullRequest
found, err := h.db.Where("id = ? AND repo_id = ?", prID, repoID).Get(&pr)
if err != nil || !found {
jsonError(w, "pull request not found", http.StatusNotFound)
return nil, false
}
return &pr, true
}
+175 -12
View File
@@ -3,36 +3,199 @@ package handlers
import (
"encoding/json"
"net/http"
"path/filepath"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/config"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
type RepoHandler struct{}
type RepoHandler struct {
db *xorm.Engine
cfg *config.Config
}
func NewRepoHandler() *RepoHandler {
return &RepoHandler{}
func NewRepoHandler(db *xorm.Engine, cfg *config.Config) *RepoHandler {
return &RepoHandler{db: db, cfg: cfg}
}
func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]any{})
userID, _ := middleware.UserIDFromContext(r.Context())
var repos []models.Repository
if err := h.db.Where("owner_id = ?", userID).Find(&repos); err != nil {
jsonError(w, "could not list repositories", http.StatusInternalServerError)
return
}
if repos == nil {
repos = []models.Repository{}
}
jsonOK(w, repos)
}
func (h *RepoHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"isPrivate"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Name == "" {
jsonError(w, "name is required", http.StatusBadRequest)
return
}
diskPath := filepath.Join(h.cfg.RepoRoot, strconv.FormatInt(userID, 10), body.Name+".git")
repo := &models.Repository{
OwnerID: userID,
Name: body.Name,
Description: body.Description,
IsPrivate: body.IsPrivate,
DefaultBranch: "main",
DiskPath: diskPath,
}
// Initialise bare repo on disk before inserting to DB
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
if err := gitdomain.Init(diskPath); err != nil {
jsonError(w, "could not initialise git repository", http.StatusInternalServerError)
return
}
if _, err := h.db.Insert(repo); err != nil {
jsonError(w, "repository name already taken", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
json.NewEncoder(w).Encode(repo)
}
func (h *RepoHandler) Get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
jsonOK(w, repo)
}
func (h *RepoHandler) Tree(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]any{})
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
subPath := r.URL.Query().Get("path")
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
entries, err := gitdomain.TreeLS(repo.DiskPath, ref, subPath)
if err != nil {
jsonError(w, "could not read tree", http.StatusInternalServerError)
return
}
if entries == nil {
entries = []gitdomain.TreeEntry{}
}
jsonOK(w, entries)
}
func (h *RepoHandler) Blob(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"content": ""})
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
path := r.URL.Query().Get("path")
if path == "" {
jsonError(w, "path is required", http.StatusBadRequest)
return
}
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
content, err := gitdomain.BlobCat(repo.DiskPath, ref, path)
if err != nil {
jsonError(w, "file not found", http.StatusNotFound)
return
}
jsonOK(w, map[string]string{"content": string(content), "path": path, "ref": ref})
}
func (h *RepoHandler) Commits(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
limit := 30
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
limit = n
}
}
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
commits, err := gitdomain.Log(repo.DiskPath, ref, limit)
if err != nil {
jsonError(w, "could not read commits", http.StatusInternalServerError)
return
}
if commits == nil {
commits = []gitdomain.Commit{}
}
jsonOK(w, commits)
}
// lookupRepo resolves {owner}/{repo} URL params to a DB row, enforcing access.
func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
// Private repo: only the owner may access (RBAC will be expanded in Phase 3)
if repo.IsPrivate {
callerID, _ := middleware.UserIDFromContext(r.Context())
if callerID != repo.OwnerID {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
}
return &repo, true
}
+90 -5
View File
@@ -5,24 +5,91 @@ import (
"net/http"
"github.com/gorilla/sessions"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type UserHandler struct {
db *xorm.Engine
store sessions.Store
}
func NewUserHandler(store sessions.Store) *UserHandler {
return &UserHandler{store: store}
func NewUserHandler(db *xorm.Engine, store sessions.Store) *UserHandler {
return &UserHandler{db: db, store: store}
}
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Username == "" || body.Email == "" || body.Password == "" {
jsonError(w, "username, email, and password are required", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
user := &models.User{
Username: body.Username,
Email: body.Email,
PasswordHash: string(hash),
}
if _, err := h.db.Insert(user); err != nil {
jsonError(w, "username or email already taken", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
var user models.User
found, err := h.db.Where("username = ?", body.Username).Get(&user)
if err != nil || !found {
jsonError(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
jsonError(w, "invalid credentials", http.StatusUnauthorized)
return
}
session, _ := h.store.Get(r, "fb_session")
session.Values["userID"] = user.ID
session.Values["username"] = user.Username
session.Values["isAdmin"] = user.IsAdmin
if err := session.Save(r, w); err != nil {
jsonError(w, "could not save session", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
@@ -31,3 +98,21 @@ func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
session.Save(r, w)
w.WriteHeader(http.StatusNoContent)
}
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
var user models.User
found, err := h.db.ID(userID).Get(&user)
if err != nil || !found {
jsonError(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
+16 -19
View File
@@ -9,13 +9,14 @@ import (
"github.com/go-chi/cors"
gcsrf "github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/handlers"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/config"
)
func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handler {
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFiles fs.FS) http.Handler {
r := chi.NewRouter()
r.Use(chimiddleware.Logger)
@@ -34,38 +35,39 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
gcsrf.Secure(!cfg.Debug),
gcsrf.SameSite(gcsrf.SameSiteLaxMode),
gcsrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "CSRF validation failed", http.StatusForbidden)
http.Error(w, `{"error":"CSRF validation failed"}`, http.StatusForbidden)
})),
)
auth := middleware.NewAuth(store)
repoH := handlers.NewRepoHandler()
userH := handlers.NewUserHandler(store)
prH := handlers.NewPRHandler()
pipeH := handlers.NewPipelineHandler()
repoH := handlers.NewRepoHandler(engine, cfg)
userH := handlers.NewUserHandler(engine, store)
prH := handlers.NewPRHandler(engine)
pipeH := handlers.NewPipelineHandler(engine)
wsH := handlers.NewWSHandler()
// Health check (no auth, no CSRF)
// Health no auth, no CSRF
r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// CSRF token endpoint for SPA bootstrap
// CSRF token bootstrap for SPA
r.With(csrfMiddleware).Get("/api/v1/csrf", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", gcsrf.Token(r))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
})
// Auth routes (CSRF protected, no session required)
// Auth (CSRF protected, no session required)
r.With(csrfMiddleware).Route("/api/v1/auth", func(r chi.Router) {
r.Post("/login", userH.Login)
r.Post("/logout", userH.Logout)
r.Post("/register", userH.Register)
})
// Authenticated API routes
// Authenticated API
r.With(csrfMiddleware).With(auth.Require).Route("/api/v1", func(r chi.Router) {
r.Get("/me", userH.Me)
@@ -76,11 +78,13 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
r.Get("/", repoH.Get)
r.Get("/tree", repoH.Tree)
r.Get("/blob", repoH.Blob)
r.Get("/commits", repoH.Commits)
r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List)
r.Post("/", prH.Create)
r.Get("/{prID}", prH.Get)
r.Post("/{prID}/merge", prH.Merge)
r.Post("/{prID}/close", prH.Close)
})
r.Route("/pipelines", func(r chi.Router) {
r.Get("/", pipeH.List)
@@ -90,10 +94,10 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
})
})
// WebSocket hub (auth via session cookie, no CSRF needed for WS)
// WebSocket — session auth only, no CSRF needed for WS upgrades
r.With(auth.Optional).Get("/ws", wsH.Hub)
// SPA fallback — serve embedded React app for all other routes
// SPA fallback
r.Handle("/*", spaHandler(staticFiles))
return r
@@ -104,13 +108,6 @@ func spaHandler(staticFiles fs.FS) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := staticFiles.Open(r.URL.Path)
if err != nil {
// Unknown path → serve index.html for client-side routing
index, err := staticFiles.Open("index.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
index.Close()
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
+31
View File
@@ -0,0 +1,31 @@
package db
import (
"fmt"
_ "github.com/lib/pq"
"xorm.io/xorm"
"xorm.io/xorm/log"
)
func Open(dataSourceName string, debug bool) (*xorm.Engine, error) {
engine, err := xorm.NewEngine("postgres", dataSourceName)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if debug {
engine.SetLogLevel(log.LOG_DEBUG)
} else {
engine.SetLogLevel(log.LOG_WARNING)
}
engine.SetMaxOpenConns(25)
engine.SetMaxIdleConns(5)
if err := engine.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return engine, nil
}
+49 -22
View File
@@ -2,6 +2,7 @@ package git
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
@@ -16,47 +17,69 @@ func SetRepoRoot(root string) {
}
// run executes a git command inside repoPath with strict safety guarantees:
// - repoPath is validated to be under the configured repoRoot
// - args are passed as discrete values — never via shell interpolation
// - the process inherits only a minimal environment
// - repoPath is validated to be under the configured repoRoot (path traversal guard)
// - args are passed as discrete values — never via shell interpolation
// - the process inherits only a minimal, fixed environment
func run(repoPath string, args ...string) ([]byte, error) {
clean := filepath.Clean(repoPath)
if repoRoot != "" && !strings.HasPrefix(clean, repoRoot+string(filepath.Separator)) && clean != repoRoot {
return nil, ErrPathTraversal
if repoRoot != "" {
root := repoRoot + string(filepath.Separator)
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
return nil, ErrPathTraversal
}
}
cmd := exec.Command("git", args...)
cmd.Dir = clean
cmd.Env = []string{
"GIT_TERMINAL_PROMPT=0",
"HOME=" + filepath.Dir(repoRoot), // needed for .gitconfig lookups
"HOME=/tmp", // prevent .gitconfig side-effects
}
return cmd.Output()
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("git %s: %w: %s", args[0], err, exitErr.Stderr)
}
return nil, fmt.Errorf("git %s: %w", args[0], err)
}
return out, nil
}
func Init(path string) error {
_, err := run(path, "init", "--bare")
return err
// git init --bare works even if the directory doesn't exist yet
cmd := exec.Command("git", "init", "--bare", path)
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init --bare: %w: %s", err, out)
}
return nil
}
type Commit struct {
Hash string
Author string
Message string
Date string
Hash string `json:"hash"`
Author string `json:"author"`
Message string `json:"message"`
Date string `json:"date"`
}
func Log(repoPath, branch string, limit int) ([]Commit, error) {
out, err := run(repoPath, "log", branch,
out, err := run(repoPath,
"log", branch,
"--format=%H\x1f%an\x1f%s\x1f%ci",
"--max-count", strings.TrimSpace(string(rune(limit+'0'))),
fmt.Sprintf("--max-count=%d", limit),
)
if err != nil {
return nil, err
}
raw := strings.TrimSpace(string(out))
if raw == "" {
return nil, nil
}
var commits []Commit
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, "\x1f")
for _, line := range strings.Split(raw, "\n") {
parts := strings.SplitN(line, "\x1f", 4)
if len(parts) != 4 {
continue
}
@@ -71,18 +94,22 @@ func Log(repoPath, branch string, limit int) ([]Commit, error) {
}
type TreeEntry struct {
Mode string
Type string
Hash string
Name string
Mode string `json:"mode"`
Type string `json:"type"`
Hash string `json:"hash"`
Name string `json:"name"`
}
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
treeRef := ref + ":" + subPath
treeRef := ref
if subPath != "" {
treeRef = ref + ":" + subPath
}
out, err := run(repoPath, "ls-tree", treeRef)
if err != nil {
return nil, err
}
var entries []TreeEntry
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
+9 -9
View File
@@ -3,13 +3,13 @@ package models
import "time"
type FederationActor struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"notnull unique index"`
APID string `xorm:"notnull unique varchar(500)"` // https://instance/users/alice
InboxURL string `xorm:"notnull varchar(500)"`
OutboxURL string `xorm:"notnull varchar(500)"`
PublicKey string `xorm:"text notnull"`
PrivateKey string `xorm:"text notnull"` // AES-GCM encrypted at rest
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
ID int64 `xorm:"pk autoincr" json:"id"`
UserID int64 `xorm:"notnull unique index" json:"userId"`
APID string `xorm:"notnull unique varchar(500)" json:"apId"`
InboxURL string `xorm:"notnull varchar(500)" json:"inboxUrl"`
OutboxURL string `xorm:"notnull varchar(500)" json:"outboxUrl"`
PublicKey string `xorm:"text notnull" json:"publicKey"`
PrivateKey string `xorm:"text notnull" json:"-"`
CreatedAt time.Time `xorm:"created" json:"createdAt"`
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
}
+10 -10
View File
@@ -11,14 +11,14 @@ const (
)
type PullRequest struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"notnull index"`
AuthorID int64 `xorm:"notnull index"`
Title string `xorm:"notnull varchar(255)"`
Body string `xorm:"text"`
SourceBranch string `xorm:"notnull varchar(255)"`
TargetBranch string `xorm:"default 'main' varchar(255)"`
Status PRStatus `xorm:"default 'open' varchar(16)"`
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
ID int64 `xorm:"pk autoincr" json:"id"`
RepoID int64 `xorm:"notnull index" json:"repoId"`
AuthorID int64 `xorm:"notnull index" json:"authorId"`
Title string `xorm:"notnull varchar(255)" json:"title"`
Body string `xorm:"text" json:"body"`
SourceBranch string `xorm:"notnull varchar(255)" json:"sourceBranch"`
TargetBranch string `xorm:"default 'main' varchar(255)" json:"targetBranch"`
Status PRStatus `xorm:"default 'open' varchar(16)" json:"status"`
CreatedAt time.Time `xorm:"created" json:"createdAt"`
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
}
+9 -9
View File
@@ -3,13 +3,13 @@ package models
import "time"
type Repository struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"notnull index"`
Name string `xorm:"notnull varchar(100)"`
Description string `xorm:"varchar(500)"`
IsPrivate bool `xorm:"default false"`
DefaultBranch string `xorm:"default 'main' varchar(255)"`
DiskPath string `xorm:"notnull"` // absolute path under REPO_ROOT
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
ID int64 `xorm:"pk autoincr" json:"id"`
OwnerID int64 `xorm:"notnull index" json:"ownerId"`
Name string `xorm:"notnull varchar(100)" json:"name"`
Description string `xorm:"varchar(500)" json:"description"`
IsPrivate bool `xorm:"default false" json:"isPrivate"`
DefaultBranch string `xorm:"default 'main' varchar(255)" json:"defaultBranch"`
DiskPath string `xorm:"notnull" json:"-"`
CreatedAt time.Time `xorm:"created" json:"createdAt"`
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
}
+7 -7
View File
@@ -3,12 +3,12 @@ package models
import "time"
type User struct {
ID int64 `xorm:"pk autoincr"`
Username string `xorm:"unique notnull varchar(64)"`
Email string `xorm:"unique notnull varchar(255)"`
PasswordHash string `xorm:"notnull"`
ID int64 `xorm:"pk autoincr" json:"id"`
Username string `xorm:"unique notnull varchar(64)" json:"username"`
Email string `xorm:"unique notnull varchar(255)" json:"email"`
PasswordHash string `xorm:"notnull" json:"-"`
AvatarURL string `xorm:"varchar(500)"`
IsAdmin bool `xorm:"default false"`
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
IsAdmin bool `xorm:"default false" json:"isAdmin"`
CreatedAt time.Time `xorm:"created" json:"createdAt"`
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
}