From 0d4ef9a9a12c946f6be8fa1b581fe1e172bfabe8 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Wed, 15 Jul 2026 17:00:07 +0700 Subject: [PATCH 01/49] Add documentation for agents, architecture, authentication, database models, and testing guidelines --- AGENTS.md | 39 +++++++++++++++++++++++++ docs/agents/architecture.md | 27 +++++++++++++++++ docs/agents/auth-and-routing.md | 22 ++++++++++++++ docs/agents/database-and-models.md | 20 +++++++++++++ docs/agents/testing-and-verification.md | 20 +++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/agents/architecture.md create mode 100644 docs/agents/auth-and-routing.md create mode 100644 docs/agents/database-and-models.md create mode 100644 docs/agents/testing-and-verification.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d7b35c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS + +Panduan ini untuk agent yang bekerja di repo ini. Tujuan utamanya adalah menjaga konsistensi arsitektur, keamanan, dan pengalaman pengembangan untuk backend Go yang menggabungkan auth serta fitur Git hosting. + +## Ringkasan repo + +- Bahasa: Go +- Framework HTTP: Gin +- ORM: GORM +- Database: PostgreSQL +- Konfigurasi: Viper dengan file YAML +- Auth: JWT untuk route yang dilindungi + +## Struktur utama + +- [main.go](main.go): entry point aplikasi +- [internal/bootstrap](internal/bootstrap): inisialisasi database dan router +- [internal/api](internal/api): controller, service, middleware, DTO, dan route +- [internal/models](internal/models): model domain dan hooks GORM +- [internal/repository](internal/repository): akses data +- [modules/githttp](modules/githttp): logika Git HTTP dan protocol terkait + +## Aturan kerja penting + +1. Pertahankan arsitektur yang ada: controller -> service -> repository. +2. Pisahkan route publik dan route yang membutuhkan auth. +3. Gunakan middleware auth untuk route yang dilindungi. +4. Hindari perubahan besar yang mengganggu kompatibilitas API. +5. Selalu tangani error secara eksplisit dan jangan sembunyikan error. +6. Pertahankan validasi input di DTO dan service. +7. Saat mengubah model atau skema, pikirkan dampaknya pada GORM dan migrasi. +8. Verifikasi hasil perubahan dengan go test dan, bila perlu, uji endpoint lewat curl atau HTTP client. + +## Dokumen terkait + +- [docs/agents/architecture.md](docs/agents/architecture.md) +- [docs/agents/auth-and-routing.md](docs/agents/auth-and-routing.md) +- [docs/agents/database-and-models.md](docs/agents/database-and-models.md) +- [docs/agents/testing-and-verification.md](docs/agents/testing-and-verification.md) diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md new file mode 100644 index 0000000..32c1fea --- /dev/null +++ b/docs/agents/architecture.md @@ -0,0 +1,27 @@ +# Arsitektur dan pola pengembangan + +## Prinsip utama + +- Gunakan pola layered yang sudah ada: controller, service, repository. +- Controller hanya menerima request dan mengembalikan response. +- Service berisi logika bisnis. +- Repository hanya berhubungan dengan database. +- Model domain sebaiknya tetap berada di internal/models. + +## Pedoman implementasi + +- Jangan menaruh logika database langsung di controller. +- Jangan menaruh logika bisnis di repository jika bisa dipindah ke service. +- Hindari membuat file besar yang menggabungkan banyak tanggung jawab. +- Saat menambahkan fitur, pilih lokasi yang konsisten dengan feature yang sudah ada. + +## Struktur folder yang disarankan + +- internal/api/controllers: handler HTTP +- internal/api/services: bisnis logic +- internal/api/dto: request/response DTO +- internal/api/middlewares: middleware Gin +- internal/api/routes: registrasi route +- internal/repository: akses data +- internal/models: model GORM +- modules/githttp: fitur Git protocol diff --git a/docs/agents/auth-and-routing.md b/docs/agents/auth-and-routing.md new file mode 100644 index 0000000..a8e88ee --- /dev/null +++ b/docs/agents/auth-and-routing.md @@ -0,0 +1,22 @@ +# Auth dan routing + +## Routing + +- Pisahkan route publik dan route yang membutuhkan auth. +- Route publik: register, login, forgot/reset password, verify email, dsb. +- Route terproteksi: logout, profile, endpoint yang butuh user context. +- Terapkan middleware auth sekali pada grup yang memang dilindungi. + +## JWT + +- Gunakan JWT untuk authenticated flow. +- Token harus dikirim lewat header Authorization dengan format Bearer . +- Validasi token dilakukan di middleware. +- Jangan menaruh logika auth langsung di controller jika sudah ada middleware. + +## Pedoman auth + +- Password harus di-hash sebelum disimpan. +- Validasi input wajib dilakukan sebelum service dipanggil. +- Jangan mengembalikan detail internal error yang terlalu sensitif ke client. +- Saat login berhasil, kirim token dan data user yang relevan. diff --git a/docs/agents/database-and-models.md b/docs/agents/database-and-models.md new file mode 100644 index 0000000..2cc95fe --- /dev/null +++ b/docs/agents/database-and-models.md @@ -0,0 +1,20 @@ +# Database dan model + +## GORM + +- Gunakan GORM untuk model dan query database. +- Model berada di internal/models. +- Hindari logika bisnis di model, kecuali hook kecil seperti hashing password atau inisialisasi field. +- Jika menambahkan hook BeforeCreate/BeforeUpdate, pastikan tidak mengganggu alur yang sudah ada. + +## Praktik yang disukai + +- Gunakan tag gorm yang sesuai untuk constraint, index, dan nullable field. +- Perhatikan nama kolom agar konsisten dengan field di database. +- Saat mengubah model, cek dampaknya pada repository dan response DTO. +- Jangan mengubah struktur model tanpa memikirkan migrasi dan kompatibilitas data. + +## Konfigurasi database + +- Konfigurasi database berasal dari Viper dan file YAML. +- Jaga agar koneksi database tetap aman dan tidak hardcode credential di source code. diff --git a/docs/agents/testing-and-verification.md b/docs/agents/testing-and-verification.md new file mode 100644 index 0000000..b40eb7f --- /dev/null +++ b/docs/agents/testing-and-verification.md @@ -0,0 +1,20 @@ +# Testing dan verifikasi + +## Standar minimal + +- Jalankan go test ./... setelah setiap perubahan yang memengaruhi build atau logika. +- Jika mengubah endpoint atau auth, uji manual lewat curl atau alat HTTP lain. +- Jika menambah fitur penting, prioritaskan unit/integration test bila memungkinkan. + +## Workflow verifikasi + +1. Jalankan gofmt jika ada perubahan Go. +2. Jalankan go test ./... +3. Jika ada fitur HTTP, uji endpoint dengan request yang relevan. +4. Pastikan response status, body, dan error handling sesuai ekspektasi. + +## Kapan wajib verifikasi lebih lanjut + +- Saat mengubah auth, JWT, middleware, atau route. +- Saat mengubah model/database. +- Saat mengubah fitur Git HTTP atau repository operations. From 746315c7fb054f36708aebaca90af7a8ee5a4ecd Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Wed, 15 Jul 2026 17:08:43 +0700 Subject: [PATCH 02/49] Implement JWT token management with access and refresh tokens, add token revocation, and update authentication routes --- AGENTS.md | 2 ++ internal/api/auth/jwt.go | 56 +++++++++++++++++++++++++++----- internal/api/auth/jwt_test.go | 51 +++++++++++++++++++++++++++++ internal/api/controllers/auth.go | 54 +++++++++++++++++++++++++++--- internal/api/dto/request/user.go | 4 +-- internal/api/routes/auth.go | 1 + 6 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 internal/api/auth/jwt_test.go diff --git a/AGENTS.md b/AGENTS.md index 2d7b35c..893f3f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ Panduan ini untuk agent yang bekerja di repo ini. Tujuan utamanya adalah menjaga 6. Pertahankan validasi input di DTO dan service. 7. Saat mengubah model atau skema, pikirkan dampaknya pada GORM dan migrasi. 8. Verifikasi hasil perubahan dengan go test dan, bila perlu, uji endpoint lewat curl atau HTTP client. +9. Jangan menjalankan aplikasi secara manual dengan go run. Jika pemilik proyek ingin menjalankan server, gunakan Air sesuai instruksi pemilik. +10. Jika ada kebutuhan menjalankan aplikasi untuk verifikasi, minta izin pemilik terlebih dahulu atau ikuti arahan yang sudah disepakati. ## Dokumen terkait diff --git a/internal/api/auth/jwt.go b/internal/api/auth/jwt.go index 62861e1..134b233 100644 --- a/internal/api/auth/jwt.go +++ b/internal/api/auth/jwt.go @@ -3,6 +3,7 @@ package auth import ( "errors" "fmt" + "sync" "time" "gitxyz/internal/models" @@ -12,12 +13,18 @@ import ( ) type Claims struct { - UserID string `json:"user_id"` - Username string `json:"username"` - Email string `json:"email"` + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + TokenType string `json:"token_type"` jwt.RegisteredClaims } +var ( + revokedTokens = map[string]struct{}{} + revokedMu sync.RWMutex +) + func getJWTSecret() string { secret := viper.GetString("jwt_secret") if secret == "" { @@ -34,15 +41,29 @@ func getJWTExpiry() time.Duration { return time.Duration(hours) * time.Hour } -func GenerateToken(user *models.User) (string, error) { +func getRefreshTokenExpiry() time.Duration { + day := viper.GetInt("jwt_refresh_expiry_days") + if day <= 0 { + day = 7 + } + return time.Duration(day) * 24 * time.Hour +} + +func GenerateToken(user *models.User, tokenType string) (string, error) { + expiry := getJWTExpiry() + if tokenType == "refresh" { + expiry = getRefreshTokenExpiry() + } + claims := Claims{ - UserID: user.ID.String(), - Username: user.Username, - Email: user.Email, + UserID: user.ID.String(), + Username: user.Username, + Email: user.Email, + TokenType: tokenType, RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(getJWTExpiry())), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), }, } @@ -51,6 +72,12 @@ func GenerateToken(user *models.User) (string, error) { } func ParseToken(tokenString string) (*Claims, error) { + revokedMu.RLock() + _, revoked := revokedTokens[tokenString] + revokedMu.RUnlock() + if revoked { + return nil, errors.New("token revoked") + } token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -67,3 +94,16 @@ func ParseToken(tokenString string) (*Claims, error) { return nil, errors.New("invalid token") } + +func RevokeToken(tokenString string) { + revokedMu.Lock() + defer revokedMu.Unlock() + revokedTokens[tokenString] = struct{}{} +} + +func IsTokenRevoked(tokenString string) bool { + revokedMu.RLock() + defer revokedMu.RUnlock() + _, ok := revokedTokens[tokenString] + return ok +} diff --git a/internal/api/auth/jwt_test.go b/internal/api/auth/jwt_test.go new file mode 100644 index 0000000..ca090d9 --- /dev/null +++ b/internal/api/auth/jwt_test.go @@ -0,0 +1,51 @@ +package auth + +import ( + "testing" + "time" + + "gitxyz/internal/models" + + "github.com/google/uuid" +) + +func TestGenerateAndParseToken(t *testing.T) { + user := &models.User{Base: models.Base{ID: uuid.New()}, Username: "alice", Email: "alice@example.com"} + + token, err := GenerateToken(user, "access") + if err != nil { + t.Fatalf("expected token generation to succeed, got error: %v", err) + } + + claims, err := ParseToken(token) + if err != nil { + t.Fatalf("expected token parsing to succeed, got error: %v", err) + } + + if claims.UserID != user.ID.String() { + t.Fatalf("expected user id %s, got %s", user.ID.String(), claims.UserID) + } + + if claims.TokenType != "access" { + t.Fatalf("expected token type access, got %s", claims.TokenType) + } + + if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) { + t.Fatal("expected token to have a future expiry") + } +} + +func TestRevokeTokenBlocksToken(t *testing.T) { + user := &models.User{Base: models.Base{ID: uuid.New()}, Username: "bob", Email: "bob@example.com"} + + token, err := GenerateToken(user, "refresh") + if err != nil { + t.Fatalf("expected refresh token generation to succeed, got error: %v", err) + } + + RevokeToken(token) + + if !IsTokenRevoked(token) { + t.Fatal("expected token to be marked as revoked") + } +} diff --git a/internal/api/controllers/auth.go b/internal/api/controllers/auth.go index 083beb7..c32a234 100644 --- a/internal/api/controllers/auth.go +++ b/internal/api/controllers/auth.go @@ -8,8 +8,10 @@ import ( "gitxyz/internal/api/services" "gitxyz/internal/models" "net/http" + "strings" "github.com/gin-gonic/gin" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -76,27 +78,71 @@ func (c *AuthControllerImpl) Login(ctx *gin.Context) { return } - user, err := c.service.Login(request.UsernameOrEmail, request.Password) + user, err := c.service.Login(request.Username, request.Password) if err != nil { ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } - token, err := auth.GenerateToken(&user) + accessToken, err := auth.GenerateToken(&user, "access") if err != nil { - ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not generate token"}) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"}) return } - ctx.JSON(http.StatusOK, gin.H{"message": "Login successful", "data": response.ToUserResponse(&user), "token": token}) + refreshToken, err := auth.GenerateToken(&user, "refresh") + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "Login successful", + "data": response.ToUserResponse(&user), + "access_token": accessToken, + "refresh_token": refreshToken, + }) } func (c *AuthControllerImpl) RefreshToken(ctx *gin.Context) { + var request struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + claims, err := auth.ParseToken(request.RefreshToken) + if err != nil || claims.TokenType != "refresh" { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + + userID, err := uuid.Parse(claims.UserID) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + + user := models.User{Base: models.Base{ID: userID}, Username: claims.Username, Email: claims.Email} + accessToken, err := auth.GenerateToken(&user, "access") + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"access_token": accessToken}) } func (c *AuthControllerImpl) Logout(ctx *gin.Context) { + authorizationHeader := ctx.GetHeader("Authorization") + parts := strings.Split(authorizationHeader, " ") + if len(parts) == 2 { + auth.RevokeToken(parts[1]) + } + ctx.JSON(http.StatusOK, gin.H{"message": "logged out"}) } func (c *AuthControllerImpl) SendVerificationEmail(ctx *gin.Context) { diff --git a/internal/api/dto/request/user.go b/internal/api/dto/request/user.go index c3bba91..99dab17 100644 --- a/internal/api/dto/request/user.go +++ b/internal/api/dto/request/user.go @@ -8,8 +8,8 @@ type RegisterRequest struct { } type LoginRequest struct { - UsernameOrEmail string `json:"username_or_email" binding:"required"` - Password string `json:"password" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` } type UpdateProfileRequest struct { diff --git a/internal/api/routes/auth.go b/internal/api/routes/auth.go index a729a05..0587120 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -15,6 +15,7 @@ func (r *RoutesImpl) RegisterAuth() { publicRoutes.POST("/register", controller.Register) publicRoutes.POST("/login", controller.Login) + publicRoutes.POST("/refresh-token", controller.RefreshToken) publicRoutes.POST("/send-verification-email", controller.SendVerificationEmail) publicRoutes.POST("/verify-email", controller.VerifyEmail) publicRoutes.POST("/send-reset-password", controller.SendPasswordReset) From b01bdb64814289b331d1af72dc90f20207007c84 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Wed, 15 Jul 2026 17:53:00 +0700 Subject: [PATCH 03/49] Implement repository management with creation functionality, validation, and routing --- go.mod | 16 +++------- go.sum | 37 ---------------------- internal/api/controllers/repo.go | 44 ++++++++++++++++++++++++-- internal/api/routes/repo.go | 15 +++++++++ internal/api/services/repo.go | 38 ++++++++++++++++++++--- internal/api/services/repo_test.go | 50 ++++++++++++++++++++++++++++++ internal/bootstrap/router.go | 1 + internal/helper/path.go | 18 +++++++++++ internal/models/repo.go | 7 +++-- internal/repository/repo.go | 32 +++++++++++++------ 10 files changed, 190 insertions(+), 68 deletions(-) create mode 100644 internal/api/services/repo_test.go create mode 100644 internal/helper/path.go diff --git a/go.mod b/go.mod index 6031d49..0d2c9b5 100644 --- a/go.mod +++ b/go.mod @@ -4,35 +4,29 @@ go 1.26.4 require ( github.com/gin-gonic/gin v1.12.0 - github.com/go-git/go-git/v6 v6.0.0-alpha.4 + github.com/go-playground/validator/v10 v10.30.3 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.54.0 gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.2 ) require ( - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect - github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.7 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect - github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect @@ -40,18 +34,16 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/go.sum b/go.sum index 6458782..3156150 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,14 @@ -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= -github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -31,16 +19,6 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= -github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ= -github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= -github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= -github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= -github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= -github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -76,15 +54,10 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= -github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -100,8 +73,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= @@ -114,8 +85,6 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -131,7 +100,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -161,18 +129,13 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/controllers/repo.go b/internal/api/controllers/repo.go index cbc7cb2..6394677 100644 --- a/internal/api/controllers/repo.go +++ b/internal/api/controllers/repo.go @@ -1,22 +1,62 @@ package controllers import ( + "net/http" + "gitxyz/internal/api/services" + "gitxyz/internal/models" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) -type RepoController interface{} +type RepoController interface { + Create(ctx *gin.Context) +} + type RepoControllerImpl struct { service services.RepoService db *gorm.DB } func NewRepoController(db *gorm.DB) RepoController { - service := services.NewRepoService() + service := services.NewRepoService(db) return &RepoControllerImpl{ service: service, db: db, } } + +func (c *RepoControllerImpl) Create(ctx *gin.Context) { + var request struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + } + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, exists := ctx.Get("user_id") + if !exists { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + repo := &models.Repository{ + Name: request.Name, + Description: request.Description, + IsPrivate: request.IsPrivate, + IsActive: true, + UserID: userID.(string), + } + + if err := c.service.CreateRepository(repo); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, gin.H{"message": "repository created", "data": repo}) +} diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 0db51ae..7b5c662 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -1 +1,16 @@ package routes + +import ( + "gitxyz/internal/api/controllers" + "gitxyz/internal/api/middlewares" +) + +func (r *RoutesImpl) RegisterRepositories() { + controller := controllers.NewRepoController(r.db) + + routes := r.engine.Group("/api/repos") + protectedRoutes := routes.Group("/") + protectedRoutes.Use(middlewares.AuthRequired()) + + protectedRoutes.POST("", controller.Create) +} diff --git a/internal/api/services/repo.go b/internal/api/services/repo.go index 15846ba..616780f 100644 --- a/internal/api/services/repo.go +++ b/internal/api/services/repo.go @@ -1,8 +1,38 @@ package services -type RepoService interface{} -type RepoServiceImpl struct{} +import ( + "errors" + "gitxyz/internal/helper" + "gitxyz/internal/models" + "gitxyz/internal/repository" -func NewRepoService() RepoService { - return &RepoServiceImpl{} + "gorm.io/gorm" +) + +type RepoService interface { + CreateRepository(repo *models.Repository) error +} + +type RepoServiceImpl struct { + Repository repository.RepoRepository +} + +func NewRepoService(db *gorm.DB) RepoService { + return &RepoServiceImpl{Repository: repository.NewRepoRepository(db)} +} + +func (s *RepoServiceImpl) CreateRepository(repo *models.Repository) error { + if repo.Name == "" { + return errors.New("repository name is required") + } + if s.Repository.ExistsByName(repo.Name) { + return errors.New("repository already exists") + } + if repo.UserID == "" { + return errors.New("user id is required") + } + + repo.PhysicalPath = helper.GenerateRepositoryPath() + + return s.Repository.Create(repo) } diff --git a/internal/api/services/repo_test.go b/internal/api/services/repo_test.go new file mode 100644 index 0000000..fee8b5a --- /dev/null +++ b/internal/api/services/repo_test.go @@ -0,0 +1,50 @@ +package services + +import ( + "gitxyz/internal/models" + "gitxyz/internal/repository" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCreateRepositoryValidation(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to connect database: %v", err) + } + + if err := db.AutoMigrate(&models.Repository{}, &models.User{}); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + service := &RepoServiceImpl{Repository: repository.NewRepoRepository(db)} + + repo := &models.Repository{Name: "", UserID: "user-1"} + if err := service.CreateRepository(repo); err == nil { + t.Fatal("expected validation error for empty name") + } +} + +func TestCreateRepositorySuccess(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to connect database: %v", err) + } + + if err := db.AutoMigrate(&models.Repository{}, &models.User{}); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + service := &RepoServiceImpl{Repository: repository.NewRepoRepository(db)} + + repo := &models.Repository{Name: "test-repo", UserID: "user-1"} + if err := service.CreateRepository(repo); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if repo.PhysicalPath == "" { + t.Fatal("physical path should be generated") + } +} diff --git a/internal/bootstrap/router.go b/internal/bootstrap/router.go index cbe6649..2e4a508 100644 --- a/internal/bootstrap/router.go +++ b/internal/bootstrap/router.go @@ -10,4 +10,5 @@ import ( func MakeRouter(engine *gin.Engine, db *gorm.DB) { router := routes.NewRoutes(engine, db) router.RegisterAuth() + router.RegisterRepositories() } diff --git a/internal/helper/path.go b/internal/helper/path.go new file mode 100644 index 0000000..72f25be --- /dev/null +++ b/internal/helper/path.go @@ -0,0 +1,18 @@ +package helper + +import ( + "crypto/sha512" + "fmt" + "path/filepath" + "time" + + "github.com/spf13/viper" +) + +func GenerateRepositoryPath() string { + hash := sha512.Sum512([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + hashStr := fmt.Sprintf("%x", hash)[:16] + + volumePath := viper.GetString("volume_path") + return filepath.Join(volumePath, hashStr) +} diff --git a/internal/models/repo.go b/internal/models/repo.go index 003ee4b..30beea2 100644 --- a/internal/models/repo.go +++ b/internal/models/repo.go @@ -3,10 +3,11 @@ package models type Repository struct { Base - Hash string `json:"hash" gorm:"size:255;not null"` - RepoName string `json:"reponame" gorm:"size:255;not null"` - PhysicalPath string `json:"physical_path" gorm:"not null"` + Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` + Description string `json:"description" gorm:"type:text"` + PhysicalPath string `json:"physical_path" gorm:"not null;uniqueIndex"` IsPrivate bool `json:"is_private" gorm:"not null;default:false"` + IsActive bool `json:"is_active" gorm:"not null;default:true"` UserID string `json:"user_id" gorm:"not null"` User User `json:"user" gorm:"foreignKey:UserID;references:ID"` diff --git a/internal/repository/repo.go b/internal/repository/repo.go index f9e4e5b..3d8539b 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -8,8 +8,11 @@ import ( ) type RepoRepository interface { + Create(repo *models.Repository) error FindById(id string) (repo models.Repository, err error) FindByPhysicalPath(path string) (repo models.Repository, err error) + FindByName(name string) (repo models.Repository, err error) + ExistsByName(name string) bool } type RepoRepositoryImpl struct { @@ -22,7 +25,7 @@ func NewRepoRepository(db *gorm.DB) *RepoRepositoryImpl { } } -func check(result *gorm.DB) (repo models.Repository, err error) { +func check(result *gorm.DB, repo *models.Repository) (models.Repository, error) { if result.Error != nil { return models.Repository{}, result.Error } @@ -30,28 +33,37 @@ func check(result *gorm.DB) (repo models.Repository, err error) { return models.Repository{}, errors.New("no repository found") } - return repo, nil + if repo == nil { + return models.Repository{}, nil + } + + return *repo, nil +} + +func (r *RepoRepositoryImpl) Create(repo *models.Repository) error { + return r.db.Create(repo).Error } func (r *RepoRepositoryImpl) FindById(id string) (repo models.Repository, err error) { - result := r.db.Find(&repo, id) + result := r.db.First(&repo, "id = ?", id) - return check(result) + return check(result, &repo) } func (r *RepoRepositoryImpl) FindByPhysicalPath(path string) (repo models.Repository, err error) { - result := r.db.Find(&repo, "physical_path = ?", path) + result := r.db.Where("physical_path = ?", path).First(&repo) - return check(result) + return check(result, &repo) } func (r *RepoRepositoryImpl) FindByName(name string) (repo models.Repository, err error) { - result := r.db.Find(&repo, "name = ?", name) + result := r.db.Where("name = ?", name).First(&repo) - return check(result) + return check(result, &repo) } func (r *RepoRepositoryImpl) ExistsByName(name string) bool { - // TODO: Implement check exists repository - return true // placeholder + var count int64 + r.db.Model(&models.Repository{}).Where("name = ?", name).Count(&count) + return count > 0 } From c038933688fc1fb3cb27225200241bde1f9b3e2a Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Wed, 15 Jul 2026 23:03:49 +0700 Subject: [PATCH 04/49] feat(migrations): add repositories table migration scripts - Created `000002_create_repositories_table.up.sql` for creating the repositories table with necessary fields and indexes. - Created `000002_create_repositories_table.down.sql` for dropping the repositories table if it exists. chore(deps): add golang-migrate for database migrations - Updated `go.mod` to include `github.com/golang-migrate/migrate/v4` for handling database migrations. - Updated `go.sum` to reflect the new dependency. refactor(database): implement migration logic in database initialization - Removed auto-migration logic and replaced it with a call to `runMigrations` during database initialization. - Added a new file `migration.go` to handle migration parsing and execution. fix(routes): adjust repository routes for cleaner path definition - Modified the route group definition in `repo.go` to remove redundant path prefix. chore: add skills-lock.json for tracking skill dependencies --- .air.toml | 5 +- .gitignore | 5 +- .../000001_create_users_table.down.sql | 1 + .../000001_create_users_table.up.sql | 19 ++ .../000002_create_repositories_table.down.sql | 1 + .../000002_create_repositories_table.up.sql | 18 ++ go.mod | 2 + go.sum | 58 +++- internal/api/routes/repo.go | 2 +- internal/bootstrap/database.go | 16 +- internal/bootstrap/migration.go | 52 ++++ skills-lock.json | 281 ++++++++++++++++++ 12 files changed, 441 insertions(+), 19 deletions(-) create mode 100644 db/migrations/000001_create_users_table.down.sql create mode 100644 db/migrations/000001_create_users_table.up.sql create mode 100644 db/migrations/000002_create_repositories_table.down.sql create mode 100644 db/migrations/000002_create_repositories_table.up.sql create mode 100644 internal/bootstrap/migration.go create mode 100644 skills-lock.json diff --git a/.air.toml b/.air.toml index d635965..3c3cebd 100644 --- a/.air.toml +++ b/.air.toml @@ -11,8 +11,8 @@ tmp_dir = "tmp" cmd = "go build -o ./tmp/main ." delay = 1000 entrypoint = ["./tmp/main"] - exclude_dir = ["assets", "tmp", "vendor", "testdata"] - exclude_file = [] + exclude_dir = ["assets", "tmp", "vendor", "testdata", ".agents", ".claude", "db"] + exclude_file = ["skills-lock.json"] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false @@ -41,6 +41,7 @@ tmp_dir = "tmp" watcher = "cyan" [log] + log = "air.log" main_only = false silent = false time = false diff --git a/.gitignore b/.gitignore index 56e066f..6c0d470 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ volumes tmp .idea ./gitxyz -.envs \ No newline at end of file +.env + +.agents +.claude \ No newline at end of file diff --git a/db/migrations/000001_create_users_table.down.sql b/db/migrations/000001_create_users_table.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/db/migrations/000001_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/db/migrations/000001_create_users_table.up.sql b/db/migrations/000001_create_users_table.up.sql new file mode 100644 index 0000000..22e2eec --- /dev/null +++ b/db/migrations/000001_create_users_table.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + avatar VARCHAR NOT NULL DEFAULT '', + bio TEXT, + location VARCHAR, + last_login_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); diff --git a/db/migrations/000002_create_repositories_table.down.sql b/db/migrations/000002_create_repositories_table.down.sql new file mode 100644 index 0000000..34a17a1 --- /dev/null +++ b/db/migrations/000002_create_repositories_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS repositories; diff --git a/db/migrations/000002_create_repositories_table.up.sql b/db/migrations/000002_create_repositories_table.up.sql new file mode 100644 index 0000000..e61506f --- /dev/null +++ b/db/migrations/000002_create_repositories_table.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE repositories ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + physical_path VARCHAR NOT NULL, + is_private BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + user_id UUID NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(name), + UNIQUE(physical_path) +); + +CREATE INDEX idx_repositories_user_id ON repositories(user_id); +CREATE INDEX idx_repositories_deleted_at ON repositories(deleted_at); diff --git a/go.mod b/go.mod index 0d2c9b5..c784527 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-playground/validator/v10 v10.30.3 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.54.0 @@ -36,6 +37,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 3156150..bf67b6d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= @@ -6,9 +10,26 @@ github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NI github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -19,6 +40,10 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -33,8 +58,12 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -62,19 +91,34 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -115,6 +159,16 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 7b5c662..9a6b76f 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -9,7 +9,7 @@ func (r *RoutesImpl) RegisterRepositories() { controller := controllers.NewRepoController(r.db) routes := r.engine.Group("/api/repos") - protectedRoutes := routes.Group("/") + protectedRoutes := routes.Group("") protectedRoutes.Use(middlewares.AuthRequired()) protectedRoutes.POST("", controller.Create) diff --git a/internal/bootstrap/database.go b/internal/bootstrap/database.go index 8b703b8..17b180d 100644 --- a/internal/bootstrap/database.go +++ b/internal/bootstrap/database.go @@ -1,23 +1,11 @@ package bootstrap import ( - "gitxyz/internal/models" - - "github.com/gin-gonic/gin" "github.com/spf13/viper" "gorm.io/driver/postgres" "gorm.io/gorm" ) -func runAutoMigrate(database *gorm.DB) { - currentMode := gin.Mode() - - if currentMode != gin.ReleaseMode { - database.AutoMigrate(&models.User{}) - database.AutoMigrate(&models.Repository{}) - } -} - func NewDatabase() *gorm.DB { DSN := viper.GetString("database_url") database, err := gorm.Open(postgres.New(postgres.Config{ @@ -28,7 +16,9 @@ func NewDatabase() *gorm.DB { panic(err) } - runAutoMigrate(database) + if err := runMigrations(DSN); err != nil { + panic(err) + } return database } diff --git a/internal/bootstrap/migration.go b/internal/bootstrap/migration.go new file mode 100644 index 0000000..54e5138 --- /dev/null +++ b/internal/bootstrap/migration.go @@ -0,0 +1,52 @@ +package bootstrap + +import ( + "fmt" + "strings" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func parseDSNToMigrateURL(dsn string) string { + parts := strings.Split(dsn, " ") + params := make(map[string]string) + + for _, part := range parts { + kv := strings.Split(part, "=") + if len(kv) == 2 { + params[kv[0]] = kv[1] + } + } + + user := params["user"] + password := params["password"] + host := params["host"] + port := params["port"] + dbname := params["dbname"] + + if port == "" { + port = "5432" + } + + url := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, dbname) + return url +} + +func runMigrations(databaseURL string) error { + migrateURL := parseDSNToMigrateURL(databaseURL) + + m, err := migrate.New("file://db/migrations", migrateURL) + if err != nil { + return fmt.Errorf("migration init failed: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migration failed: %w", err) + } + + fmt.Println("Migrations completed successfully") + return nil +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..8e9c56b --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,281 @@ +{ + "version": 1, + "skills": { + "golang-benchmark": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-benchmark/SKILL.md", + "computedHash": "68ab26e469db7857c3aea12d010f94ad2c6e6ffd37e6f2307bdc0483a1725e74" + }, + "golang-cli": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-cli/SKILL.md", + "computedHash": "1901746f0c3c32b219ee06c750e386c2bbdb76978419b8d662aad68cb6759eb6" + }, + "golang-code-style": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-code-style/SKILL.md", + "computedHash": "1c949d9721fac34faf39e640a08c9f17b6449a64183824ac99d2e3785b1fef57" + }, + "golang-concurrency": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-concurrency/SKILL.md", + "computedHash": "599a74eed42371e5e82f8afe0eb42805a1807341d998c8d85cf8232303b66c46" + }, + "golang-context": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-context/SKILL.md", + "computedHash": "17351f3065a9857759db7f279f693eeacb9a4d6c84ddac5c9c74a08ca1d04b62" + }, + "golang-continuous-integration": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-continuous-integration/SKILL.md", + "computedHash": "3e05e6159414419c167712b788bf0a066818b514c3385a2907de2c33ee886f1a" + }, + "golang-data-structures": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-data-structures/SKILL.md", + "computedHash": "1fd9e735f6e30f22638fc76a0b3583e37a279d362b7ea08c7ad8a381b9c26721" + }, + "golang-database": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-database/SKILL.md", + "computedHash": "909e31dbc8bb6eea2851985825a81dc53e954c31e76c31bbeaa5b690ee0de9ff" + }, + "golang-dependency-injection": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-dependency-injection/SKILL.md", + "computedHash": "ddc61a1dade6025c285a2c24e4efab2d8ee17bdc4ed0bab84663eb25fb6f03f2" + }, + "golang-dependency-management": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-dependency-management/SKILL.md", + "computedHash": "2a308393ed1d6624e6c5c59a12a9aab25431d237e1e543d03aa97f1121950ab3" + }, + "golang-design-patterns": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-design-patterns/SKILL.md", + "computedHash": "be80b0298aa92d2563dae317e4558f96f0f4990684254c41e644bf35b7c40b5e" + }, + "golang-documentation": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-documentation/SKILL.md", + "computedHash": "9ac7585cd6df629c6862c68c4f65db1cb16ea73d3c6655c6fb5dacc89ac36fba" + }, + "golang-error-handling": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-error-handling/SKILL.md", + "computedHash": "1601d92a30a221903d024d4ad470595c3c6141d14a5b274a446394adefc83a52" + }, + "golang-google-wire": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-google-wire/SKILL.md", + "computedHash": "809cd5676966febc7b36a09a63f37b71d7945a5ea59cc9b51c03cb9b9b1a2937" + }, + "golang-gopls": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-gopls/SKILL.md", + "computedHash": "f92d164da860d10ca171d8127602383010be173ae8858eab92fe8b028f0e4231" + }, + "golang-graphql": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-graphql/SKILL.md", + "computedHash": "11aef7592117d7cb8cc63a6e26436ae073bb0b5f9e68f996c11a1bbaa2e98c59" + }, + "golang-grpc": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-grpc/SKILL.md", + "computedHash": "14355dae5b44931ecefb748b10383e55bd7a39551e804477b7207c04e3bc6f11" + }, + "golang-how-to": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-how-to/SKILL.md", + "computedHash": "8f09920b4883fd673a985ae1f065dc66289a47cef6258b3cc8afc20da21acf35" + }, + "golang-lint": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-lint/SKILL.md", + "computedHash": "ea282d6b3a39d02fc468ef66615830de75f720c66ac72e899c4479868bea6c76" + }, + "golang-modernize": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-modernize/SKILL.md", + "computedHash": "923780b2686c61be6b39f8ab201ad6449457a718b0484cff3b115c481f06af73" + }, + "golang-naming": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-naming/SKILL.md", + "computedHash": "7c2f78c3494f46599b85aa33a5b8d2b05b7d6cedf596247d862877b79d20d442" + }, + "golang-observability": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-observability/SKILL.md", + "computedHash": "aa70cddb5f28db0682dce7d5327e63f1abbf48705dc61786aa13849e7781c833" + }, + "golang-performance": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-performance/SKILL.md", + "computedHash": "56a9621bfd52c2674a27217f8e223df117b605b15fe06be8ec666c5d2c02604e" + }, + "golang-pkg-go-dev": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-pkg-go-dev/SKILL.md", + "computedHash": "e44d5d8b5f80b272399139c3270986b484b2c45a4e3ae3381160bcb15216c231" + }, + "golang-popular-libraries": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-popular-libraries/SKILL.md", + "computedHash": "08e31427af44cd75ee5b9b64e559edb09df037209e6785ff77c437abf1483b22" + }, + "golang-project-layout": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-project-layout/SKILL.md", + "computedHash": "34819e5ffea9ae29528a576a1913539ba66cb2d7d6708962922c296c328702a4" + }, + "golang-refactoring": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-refactoring/SKILL.md", + "computedHash": "2af895968756f63a123fccca14b83adf5465cb636327fc86678609082d39060b" + }, + "golang-safety": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-safety/SKILL.md", + "computedHash": "dc54b4e717130dfe632f8996d8a72b8946fa966e3711f12a49b3d6573d6df466" + }, + "golang-samber-do": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-do/SKILL.md", + "computedHash": "9458c8d3719fc556090a72d1a339c975809e9f5794e12a7fab77d34417ec979f" + }, + "golang-samber-hot": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-hot/SKILL.md", + "computedHash": "44f0f41e90396ffd73ea5a0a973118653b579baa3f628abc032aee745b25b2e6" + }, + "golang-samber-lo": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-lo/SKILL.md", + "computedHash": "0891a177c8c6d552bb59d42b09dd39e44eb6528ed4c9ad66c8161867325eaf9a" + }, + "golang-samber-mo": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-mo/SKILL.md", + "computedHash": "4a4eaa554c9f6c6705b9465084a94696f8e92d78f61e0093a2edc658d0f2fb4c" + }, + "golang-samber-oops": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-oops/SKILL.md", + "computedHash": "7202aa8853513f358eda71d422dfc5f16563e3863e47de4a6e8f7e2ec05648c2" + }, + "golang-samber-ro": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-ro/SKILL.md", + "computedHash": "4c6b5d0e6e4ba0646cad34ccf283ab91651b2310b0955e072d916d23c85d5cc1" + }, + "golang-samber-slog": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-samber-slog/SKILL.md", + "computedHash": "baaf12a31a156c7a43988f77b6f2252827d7f9d1b155ce337cf853c82e8d14eb" + }, + "golang-security": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-security/SKILL.md", + "computedHash": "ec7a8da21c7ca213783cc1631c3496c841209ba92fdf8155c1c9e45dfee38c94" + }, + "golang-spf13-cobra": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-spf13-cobra/SKILL.md", + "computedHash": "dcfa99ce23eef99133df4b82fd1152c9ef8e1fef8fc467db2fb8f62398f7747f" + }, + "golang-spf13-viper": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-spf13-viper/SKILL.md", + "computedHash": "505e169ef1bf75acefc2c2f8ec9c6d0f1cfc9ad48c1f14730b72359596a3b6d5" + }, + "golang-stay-updated": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-stay-updated/SKILL.md", + "computedHash": "76ad4a471595b1e2ff49a1cfe3ce813e9909c0d220524a956c2fa586910ef376" + }, + "golang-stretchr-testify": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-stretchr-testify/SKILL.md", + "computedHash": "309c5ed8d19ffefe86fce6c3f950f3ef008415ac9670feedee8de852f7eba24d" + }, + "golang-structs-interfaces": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-structs-interfaces/SKILL.md", + "computedHash": "421cf8eab6c55edc0e3a47d74daaf50d5eded6c5d40fa289b1cda873bdcb8243" + }, + "golang-swagger": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-swagger/SKILL.md", + "computedHash": "8b9263be170011dd5242dd31147853abdcc496fde232dba6346ae7435b523882" + }, + "golang-testing": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-testing/SKILL.md", + "computedHash": "150dfb5fe4c56b8d3c141c4b3e70326743b591c1dedf9a32cfba64619bab8784" + }, + "golang-troubleshooting": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-troubleshooting/SKILL.md", + "computedHash": "cd547f6013dc017af54d16baf37cca1a0cac3c1db61a728b68828659aae71d55" + }, + "golang-uber-dig": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-uber-dig/SKILL.md", + "computedHash": "587984786422e897d253ebd76ad1278db7de1a11cec2136f2b468e828bad2f51" + }, + "golang-uber-fx": { + "source": "samber/cc-skills-golang", + "sourceType": "github", + "skillPath": "skills/golang-uber-fx/SKILL.md", + "computedHash": "4b77c609828aa3de034247b1c19d2ed2230be89015cc930dfe1ee0bfe2be1b48" + } + } +} From c4d0bbfb3b5826a6a85fdf368acc564ea23ee704 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:30:55 +0700 Subject: [PATCH 05/49] feat(migrations): add user credentials tables for SSH keys and personal access tokens --- ...003_create_user_credentials_table.down.sql | 2 ++ ...00003_create_user_credentials_table.up.sql | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 db/migrations/000003_create_user_credentials_table.down.sql create mode 100644 db/migrations/000003_create_user_credentials_table.up.sql diff --git a/db/migrations/000003_create_user_credentials_table.down.sql b/db/migrations/000003_create_user_credentials_table.down.sql new file mode 100644 index 0000000..a4ddfc8 --- /dev/null +++ b/db/migrations/000003_create_user_credentials_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS personal_access_tokens; +DROP TABLE IF EXISTS ssh_keys; diff --git a/db/migrations/000003_create_user_credentials_table.up.sql b/db/migrations/000003_create_user_credentials_table.up.sql new file mode 100644 index 0000000..14ad0d0 --- /dev/null +++ b/db/migrations/000003_create_user_credentials_table.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE ssh_keys ( + id UUID PRIMARY KEY, + title VARCHAR(255) NOT NULL, + public_key TEXT NOT NULL, + fingerprint VARCHAR(255) NOT NULL, + user_id UUID NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(fingerprint) +); + +CREATE INDEX idx_ssh_keys_user_id ON ssh_keys(user_id); +CREATE INDEX idx_ssh_keys_deleted_at ON ssh_keys(deleted_at); + +CREATE TABLE personal_access_tokens ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + token_hash VARCHAR(255) NOT NULL, + token_prefix VARCHAR(16) NOT NULL, + scopes TEXT, + last_used_at TIMESTAMP, + expires_at TIMESTAMP, + user_id UUID NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(token_hash) +); + +CREATE INDEX idx_personal_access_tokens_user_id ON personal_access_tokens(user_id); +CREATE INDEX idx_personal_access_tokens_deleted_at ON personal_access_tokens(deleted_at); From d60b445b354f0561d68bb01f6b5a0cdbb6826dff Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:31:13 +0700 Subject: [PATCH 06/49] feat(user): implement user management with SSH keys and personal access tokens --- internal/api/controllers/auth.go | 3 + internal/api/controllers/profile.go | 88 +++++++++++++-- internal/api/controllers/repo.go | 7 +- internal/api/controllers/user.go | 164 ++++++++++++++++++++++++++++ internal/api/dto/request/user.go | 10 ++ internal/api/dto/response/repo.go | 49 +++++++++ internal/api/dto/response/user.go | 54 +++++++++ internal/api/routes/auth.go | 55 +++++++--- internal/api/services/auth.go | 57 ++++++++++ internal/api/services/user.go | 121 ++++++++++++++++++++ internal/helper/token.go | 42 +++++++ internal/models/keys.go | 41 +++++++ internal/repository/keys.go | 150 +++++++++++++++++++++++++ 13 files changed, 815 insertions(+), 26 deletions(-) create mode 100644 internal/api/controllers/user.go create mode 100644 internal/api/dto/response/repo.go create mode 100644 internal/api/services/user.go create mode 100644 internal/helper/token.go create mode 100644 internal/models/keys.go create mode 100644 internal/repository/keys.go diff --git a/internal/api/controllers/auth.go b/internal/api/controllers/auth.go index c32a234..fb2abf7 100644 --- a/internal/api/controllers/auth.go +++ b/internal/api/controllers/auth.go @@ -20,6 +20,9 @@ type ( Register(ctx *gin.Context) Login(ctx *gin.Context) Profile(ctx *gin.Context) + GetUserByUsername(ctx *gin.Context) + UpdateProfile(ctx *gin.Context) + ChangePassword(ctx *gin.Context) RefreshToken(ctx *gin.Context) Logout(ctx *gin.Context) SendVerificationEmail(ctx *gin.Context) diff --git a/internal/api/controllers/profile.go b/internal/api/controllers/profile.go index 7d8de87..9580bbc 100644 --- a/internal/api/controllers/profile.go +++ b/internal/api/controllers/profile.go @@ -3,20 +3,92 @@ package controllers import ( "net/http" + dto "gitxyz/internal/api/dto/request" + response "gitxyz/internal/api/dto/response" + "github.com/gin-gonic/gin" ) func (c *AuthControllerImpl) Profile(ctx *gin.Context) { - userID, _ := ctx.Get("user_id") - username, _ := ctx.Get("username") - email, _ := ctx.Get("email") + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + user, err := c.service.GetUserByID(userID.(string)) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } ctx.JSON(http.StatusOK, gin.H{ "message": "profile fetched", - "data": gin.H{ - "user_id": userID, - "username": username, - "email": email, - }, + "data": response.ToUserResponse(&user), + }) +} + +func (c *AuthControllerImpl) GetUserByUsername(ctx *gin.Context) { + username := ctx.Param("username") + if username == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "username is required"}) + return + } + + user, err := c.service.GetUserByUsername(username) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "user fetched", + "data": response.ToUserResponse(&user), + }) +} + +func (c *AuthControllerImpl) UpdateProfile(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + var request dto.UpdateProfileRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := c.service.UpdateProfile(userID.(string), request.FullName, request.Bio, request.Location, request.Avatar) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "profile updated", + "data": response.ToUserResponse(&user), }) } + +func (c *AuthControllerImpl) ChangePassword(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + var request dto.ChangePasswordRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.service.ChangePassword(userID.(string), request.OldPassword, request.NewPassword); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "password changed successfully"}) +} diff --git a/internal/api/controllers/repo.go b/internal/api/controllers/repo.go index 6394677..887f934 100644 --- a/internal/api/controllers/repo.go +++ b/internal/api/controllers/repo.go @@ -3,6 +3,7 @@ package controllers import ( "net/http" + response "gitxyz/internal/api/dto/response" "gitxyz/internal/api/services" "gitxyz/internal/models" @@ -44,6 +45,7 @@ func (c *RepoControllerImpl) Create(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) return } + owner, _ := ctx.Get("username") repo := &models.Repository{ Name: request.Name, @@ -58,5 +60,8 @@ func (c *RepoControllerImpl) Create(ctx *gin.Context) { return } - ctx.JSON(http.StatusCreated, gin.H{"message": "repository created", "data": repo}) + ctx.JSON(http.StatusCreated, gin.H{ + "message": "repository created", + "data": response.ToRepositoryResponse(repo, owner.(string)), + }) } diff --git a/internal/api/controllers/user.go b/internal/api/controllers/user.go new file mode 100644 index 0000000..70749e8 --- /dev/null +++ b/internal/api/controllers/user.go @@ -0,0 +1,164 @@ +package controllers + +import ( + "net/http" + + dto "gitxyz/internal/api/dto/request" + response "gitxyz/internal/api/dto/response" + "gitxyz/internal/api/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type UserController interface { + ListSSHKeys(ctx *gin.Context) + AddSSHKey(ctx *gin.Context) + DeleteSSHKey(ctx *gin.Context) + ListTokens(ctx *gin.Context) + CreateToken(ctx *gin.Context) + DeleteToken(ctx *gin.Context) +} + +type UserControllerImpl struct { + service services.UserService + db *gorm.DB +} + +func NewUserController(db *gorm.DB) UserController { + return &UserControllerImpl{ + service: services.NewUserService(db), + db: db, + } +} + +func (c *UserControllerImpl) ListSSHKeys(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + keys, err := c.service.ListSSHKeys(userID.(string)) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "ssh keys fetched", + "data": response.ToSSHKeyResponseSlice(keys), + }) +} + +func (c *UserControllerImpl) AddSSHKey(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + var request dto.AddSSHKeyRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + key, err := c.service.AddSSHKey(userID.(string), request.Title, request.PublicKey) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "message": "ssh key added", + "data": response.ToSSHKeyResponse(&key), + }) +} + +func (c *UserControllerImpl) DeleteSSHKey(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + keyID := ctx.Param("id") + if keyID == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "key id is required"}) + return + } + + if err := c.service.DeleteSSHKey(userID.(string), keyID); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "ssh key deleted"}) +} + +func (c *UserControllerImpl) ListTokens(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + tokens, err := c.service.ListTokens(userID.(string)) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "tokens fetched", + "data": response.ToTokenResponseSlice(tokens), + }) +} + +func (c *UserControllerImpl) CreateToken(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + var request dto.CreateTokenRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token, plain, err := c.service.CreateToken(userID.(string), request.Name, request.Scopes) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "message": "token created", + "data": response.ToTokenResponse(&token), + "access_token": plain, + }) +} + +func (c *UserControllerImpl) DeleteToken(ctx *gin.Context) { + userID, ok := ctx.Get("user_id") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user context missing"}) + return + } + + tokenID := ctx.Param("id") + if tokenID == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "token id is required"}) + return + } + + if err := c.service.DeleteToken(userID.(string), tokenID); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "token deleted"}) +} diff --git a/internal/api/dto/request/user.go b/internal/api/dto/request/user.go index 99dab17..07747ad 100644 --- a/internal/api/dto/request/user.go +++ b/internal/api/dto/request/user.go @@ -27,3 +27,13 @@ type ChangePasswordRequest struct { OldPassword string `json:"old_password" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=6,max=24"` } + +type AddSSHKeyRequest struct { + Title string `json:"title" binding:"required"` + PublicKey string `json:"public_key" binding:"required"` +} + +type CreateTokenRequest struct { + Name string `json:"name" binding:"required"` + Scopes string `json:"scopes"` +} diff --git a/internal/api/dto/response/repo.go b/internal/api/dto/response/repo.go new file mode 100644 index 0000000..13efc8d --- /dev/null +++ b/internal/api/dto/response/repo.go @@ -0,0 +1,49 @@ +package dto + +import ( + "gitxyz/internal/models" + "time" +) + +// RepositoryResponse is the safe, filtered projection of a Repository that is +// returned to API clients. It intentionally omits the nested User association +// and GORM internals (e.g. DeletedAt) to avoid leaking zero-value structs. +type RepositoryResponse struct { + ID string `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + IsActive bool `json:"is_active"` + UserID string `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToRepositoryResponse builds the response. owner is the repository path +// prefix (typically the owner's username), e.g. "gitxyz" for "gitxyz/gitxyz". +func ToRepositoryResponse(repo *models.Repository, owner string) RepositoryResponse { + fullName := repo.Name + if owner != "" { + fullName = owner + "/" + repo.Name + } + return RepositoryResponse{ + ID: repo.ID.String(), + Name: repo.Name, + FullName: fullName, + Description: repo.Description, + IsPrivate: repo.IsPrivate, + IsActive: repo.IsActive, + UserID: repo.UserID, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, + } +} + +func ToRepositoryResponseSlice(repos []models.Repository, owner string) []RepositoryResponse { + out := make([]RepositoryResponse, 0, len(repos)) + for i := range repos { + out = append(out, ToRepositoryResponse(&repos[i], owner)) + } + return out +} diff --git a/internal/api/dto/response/user.go b/internal/api/dto/response/user.go index 9ca6d73..51dfcd6 100644 --- a/internal/api/dto/response/user.go +++ b/internal/api/dto/response/user.go @@ -5,6 +5,60 @@ import ( "time" ) +type SSHKeyResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` +} + +func ToSSHKeyResponse(key *models.SSHKey) SSHKeyResponse { + return SSHKeyResponse{ + ID: key.ID.String(), + Title: key.Title, + Fingerprint: key.Fingerprint, + CreatedAt: key.CreatedAt, + } +} + +type TokenResponse struct { + ID string `json:"id"` + Name string `json:"name"` + TokenPrefix string `json:"token_prefix"` + Scopes string `json:"scopes"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +func ToTokenResponse(token *models.PersonalAccessToken) TokenResponse { + return TokenResponse{ + ID: token.ID.String(), + Name: token.Name, + TokenPrefix: token.TokenPrefix, + Scopes: token.Scopes, + LastUsedAt: token.LastUsedAt, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + } +} + +func ToSSHKeyResponseSlice(keys []models.SSHKey) []SSHKeyResponse { + out := make([]SSHKeyResponse, 0, len(keys)) + for i := range keys { + out = append(out, ToSSHKeyResponse(&keys[i])) + } + return out +} + +func ToTokenResponseSlice(tokens []models.PersonalAccessToken) []TokenResponse { + out := make([]TokenResponse, 0, len(tokens)) + for i := range tokens { + out = append(out, ToTokenResponse(&tokens[i])) + } + return out +} + type UserResponse struct { ID string `json:"id"` FullName string `json:"full_name"` diff --git a/internal/api/routes/auth.go b/internal/api/routes/auth.go index 0587120..e56f776 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -6,21 +6,42 @@ import ( ) func (r *RoutesImpl) RegisterAuth() { - controller := controllers.NewAuthController(r.db) - - routes := r.engine.Group("/api/auth") - publicRoutes := routes.Group("/") - protectedRoutes := routes.Group("/") - protectedRoutes.Use(middlewares.AuthRequired()) - - publicRoutes.POST("/register", controller.Register) - publicRoutes.POST("/login", controller.Login) - publicRoutes.POST("/refresh-token", controller.RefreshToken) - publicRoutes.POST("/send-verification-email", controller.SendVerificationEmail) - publicRoutes.POST("/verify-email", controller.VerifyEmail) - publicRoutes.POST("/send-reset-password", controller.SendPasswordReset) - publicRoutes.POST("/reset-password", controller.ResetPassword) - - protectedRoutes.POST("/logout", controller.Logout) - protectedRoutes.GET("/me", controller.Profile) + authController := controllers.NewAuthController(r.db) + userController := controllers.NewUserController(r.db) + + // --- Auth routes (ENDPOINT.md §2) --- + auth := r.engine.Group("/api/auth") + public := auth.Group("/") + protected := auth.Group("/") + protected.Use(middlewares.AuthRequired()) + + public.POST("/register", authController.Register) + public.POST("/login", authController.Login) + public.POST("/token/refresh", authController.RefreshToken) + public.POST("/send-verification-email", authController.SendVerificationEmail) + public.POST("/verify-email", authController.VerifyEmail) + public.POST("/send-reset-password", authController.SendPasswordReset) + public.POST("/reset-password", authController.ResetPassword) + + protected.POST("/logout", authController.Logout) + protected.GET("/me", authController.Profile) + + // --- User routes (ENDPOINT.md §2) --- + user := r.engine.Group("/api/user") + user.Use(middlewares.AuthRequired()) + + user.GET("", authController.Profile) // GET /user — profil sendiri + user.PATCH("", authController.UpdateProfile) // PATCH /user + user.POST("/change-password", authController.ChangePassword) + + user.GET("/keys", userController.ListSSHKeys) // GET /user/keys + user.POST("/keys", userController.AddSSHKey) // POST /user/keys + user.DELETE("/keys/:id", userController.DeleteSSHKey) + + user.GET("/tokens", userController.ListTokens) // GET /user/tokens + user.POST("/tokens", userController.CreateToken) // POST /user/tokens + user.DELETE("/tokens/:id", userController.DeleteToken) + + // Public user lookup + r.engine.GET("/api/users/:username", authController.GetUserByUsername) } diff --git a/internal/api/services/auth.go b/internal/api/services/auth.go index aab00de..8a5dd78 100644 --- a/internal/api/services/auth.go +++ b/internal/api/services/auth.go @@ -3,6 +3,7 @@ package services import ( "errors" "fmt" + "gitxyz/internal/helper" "gitxyz/internal/models" "gitxyz/internal/repository" @@ -13,6 +14,10 @@ import ( type AuthService interface { Register(user *models.User) error Login(usernameOrEmail, password string) (models.User, error) + GetUserByID(id string) (models.User, error) + GetUserByUsername(username string) (models.User, error) + UpdateProfile(id string, fullName, bio, location, avatar string) (models.User, error) + ChangePassword(id, oldPassword, newPassword string) error } type AuthServiceImpl struct { @@ -45,3 +50,55 @@ func (a *AuthServiceImpl) Register(user *models.User) error { func (a *AuthServiceImpl) Login(usernameOrEmail, password string) (models.User, error) { return a.Repository.Authenticate(usernameOrEmail, password) } + +func (a *AuthServiceImpl) GetUserByID(id string) (models.User, error) { + return a.Repository.FindByID(id) +} + +func (a *AuthServiceImpl) GetUserByUsername(username string) (models.User, error) { + return a.Repository.FindByUsername(username) +} + +func (a *AuthServiceImpl) UpdateProfile(id, fullName, bio, location, avatar string) (models.User, error) { + user, err := a.Repository.FindByID(id) + if err != nil { + return models.User{}, err + } + + if fullName != "" { + user.FullName = fullName + } + if bio != "" { + user.Bio = bio + } + if location != "" { + user.Location = location + } + if avatar != "" { + user.Avatar = avatar + } + + if err := a.Repository.Update(&user); err != nil { + return models.User{}, err + } + return user, nil +} + +func (a *AuthServiceImpl) ChangePassword(id, oldPassword, newPassword string) error { + user, err := a.Repository.FindByID(id) + if err != nil { + return err + } + + if err := helper.ComparePassword(user.Password, oldPassword); err != nil { + return errors.New("old password is incorrect") + } + + hashed, err := helper.HashPassword(newPassword) + if err != nil { + return err + } + user.Password = hashed + + return a.Repository.Update(&user) +} diff --git a/internal/api/services/user.go b/internal/api/services/user.go new file mode 100644 index 0000000..e1ec74c --- /dev/null +++ b/internal/api/services/user.go @@ -0,0 +1,121 @@ +package services + +import ( + "errors" + "gitxyz/internal/helper" + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "gorm.io/gorm" +) + +type UserService interface { + // SSH keys + AddSSHKey(userID, title, publicKey string) (models.SSHKey, error) + ListSSHKeys(userID string) ([]models.SSHKey, error) + DeleteSSHKey(userID, keyID string) error + + // Personal access tokens + CreateToken(userID, name, scopes string) (models.PersonalAccessToken, string, error) + ListTokens(userID string) ([]models.PersonalAccessToken, error) + DeleteToken(userID, tokenID string) error +} + +type UserServiceImpl struct { + Keys repository.SSHKeyRepository + Tokens repository.PATRepository +} + +func NewUserService(db *gorm.DB) UserService { + return &UserServiceImpl{ + Keys: repository.NewSSHKeyRepository(db), + Tokens: repository.NewPATRepository(db), + } +} + +func (s *UserServiceImpl) AddSSHKey(userID, title, publicKey string) (models.SSHKey, error) { + if title == "" { + return models.SSHKey{}, errors.New("key title is required") + } + if publicKey == "" { + return models.SSHKey{}, errors.New("public key is required") + } + + fingerprint, err := helper.FingerprintSSHKey(publicKey) + if err != nil { + return models.SSHKey{}, err + } + + if s.Keys.ExistsByFingerprint(fingerprint) { + return models.SSHKey{}, errors.New("this ssh key is already registered") + } + + key := &models.SSHKey{ + Title: title, + PublicKey: publicKey, + Fingerprint: fingerprint, + UserID: userID, + } + + if err := s.Keys.Create(key); err != nil { + return models.SSHKey{}, err + } + return *key, nil +} + +func (s *UserServiceImpl) ListSSHKeys(userID string) ([]models.SSHKey, error) { + return s.Keys.FindByUserID(userID) +} + +func (s *UserServiceImpl) DeleteSSHKey(userID, keyID string) error { + key, err := s.Keys.FindByID(keyID) + if err != nil { + return err + } + if key.UserID != userID { + return errors.New("not authorized to delete this key") + } + return s.Keys.Delete(keyID) +} + +func (s *UserServiceImpl) CreateToken(userID, name, scopes string) (models.PersonalAccessToken, string, error) { + if name == "" { + return models.PersonalAccessToken{}, "", errors.New("token name is required") + } + + plain, prefix, hash, err := helper.GenerateToken() + if err != nil { + return models.PersonalAccessToken{}, "", err + } + + token := &models.PersonalAccessToken{ + Name: name, + TokenHash: hash, + TokenPrefix: prefix, + Scopes: scopes, + UserID: userID, + } + + if err := s.Tokens.Create(token); err != nil { + return models.PersonalAccessToken{}, "", err + } + // Return the plaintext token only once, at creation time. + return *token, plain, nil +} + +func (s *UserServiceImpl) ListTokens(userID string) ([]models.PersonalAccessToken, error) { + return s.Tokens.FindByUserID(userID) +} + +func (s *UserServiceImpl) DeleteToken(userID, tokenID string) error { + token, err := s.Tokens.FindByID(tokenID) + if err != nil { + return err + } + if token.UserID != userID { + return errors.New("not authorized to delete this token") + } + return s.Tokens.Delete(tokenID) +} + +var _ = gorm.ErrRecordNotFound diff --git a/internal/helper/token.go b/internal/helper/token.go new file mode 100644 index 0000000..cc0448f --- /dev/null +++ b/internal/helper/token.go @@ -0,0 +1,42 @@ +package helper + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/ssh" +) + +// GenerateToken returns a high-entropy random token (hex) and its prefix +// (first 8 chars) used for display and lookup. +func GenerateToken() (plain string, prefix string, hash string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return "", "", "", err + } + plain = hex.EncodeToString(b) + prefix = plain[:8] + hash = HashToken(plain) + return plain, prefix, hash, nil +} + +// HashToken hashes a plaintext token with SHA-256 for storage. +func HashToken(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return hex.EncodeToString(sum[:]) +} + +// FingerprintSSHKey computes an OpenSSH-style MD5 fingerprint of a public key, +// e.g. "SHA256:abcd...". Accepts the raw authorized_keys line. +func FingerprintSSHKey(publicKey string) (string, error) { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKey)) + if err != nil { + return "", fmt.Errorf("invalid ssh public key: %w", err) + } + // OpenSSH SHA256 fingerprint (no trailing algorithm label). + fp := ssh.FingerprintSHA256(pub) + // fp is "SHA256:xxxx"; return as-is for clarity. + return fp, nil +} diff --git a/internal/models/keys.go b/internal/models/keys.go new file mode 100644 index 0000000..f65b342 --- /dev/null +++ b/internal/models/keys.go @@ -0,0 +1,41 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// SSHKey represents a user's registered SSH public key, used for Git over SSH +// and as an authentication credential. +type SSHKey struct { + Base + + Title string `json:"title" gorm:"size:255;not null"` + PublicKey string `json:"-" gorm:"type:text;not null"` + Fingerprint string `json:"fingerprint" gorm:"size:255;not null"` + UserID string `json:"user_id" gorm:"not null;index"` + User User `json:"user" gorm:"foreignKey:UserID;references:ID"` +} + +// PersonalAccessToken represents a PAT used to authenticate against the Git +// protocol and the API, instead of the user's password. +type PersonalAccessToken struct { + Base + + Name string `json:"name" gorm:"size:255;not null"` + TokenHash string `json:"-" gorm:"size:255;not null"` + TokenPrefix string `json:"token_prefix" gorm:"size:16;not null"` + Scopes string `json:"scopes" gorm:"type:text"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + UserID string `json:"user_id" gorm:"not null;index"` + User User `json:"user" gorm:"foreignKey:UserID;references:ID"` +} + +// TableName overrides to keep a stable, explicit table name. +func (SSHKey) TableName() string { return "ssh_keys" } + +func (PersonalAccessToken) TableName() string { return "personal_access_tokens" } + +var _ = uuid.New diff --git a/internal/repository/keys.go b/internal/repository/keys.go new file mode 100644 index 0000000..f59f599 --- /dev/null +++ b/internal/repository/keys.go @@ -0,0 +1,150 @@ +package repository + +import ( + "errors" + "gitxyz/internal/models" + + "gorm.io/gorm" +) + +type SSHKeyRepository interface { + Create(key *models.SSHKey) error + FindByID(id string) (models.SSHKey, error) + FindByUserID(userID string) ([]models.SSHKey, error) + FindByFingerprint(fingerprint string) (models.SSHKey, error) + Delete(id string) error + ExistsByFingerprint(fingerprint string) bool +} + +type SSHKeyRepositoryImpl struct { + db *gorm.DB +} + +func NewSSHKeyRepository(db *gorm.DB) *SSHKeyRepositoryImpl { + return &SSHKeyRepositoryImpl{db: db} +} + +func (r *SSHKeyRepositoryImpl) Create(key *models.SSHKey) error { + return r.db.Create(key).Error +} + +func (r *SSHKeyRepositoryImpl) FindByID(id string) (models.SSHKey, error) { + var key models.SSHKey + result := r.db.First(&key, "id = ?", id) + if result.Error != nil { + return models.SSHKey{}, result.Error + } + if result.RowsAffected == 0 { + return models.SSHKey{}, gorm.ErrRecordNotFound + } + return key, nil +} + +func (r *SSHKeyRepositoryImpl) FindByUserID(userID string) ([]models.SSHKey, error) { + var keys []models.SSHKey + result := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&keys) + if result.Error != nil { + return nil, result.Error + } + return keys, nil +} + +func (r *SSHKeyRepositoryImpl) FindByFingerprint(fingerprint string) (models.SSHKey, error) { + var key models.SSHKey + result := r.db.Where("fingerprint = ?", fingerprint).First(&key) + if result.Error != nil { + return models.SSHKey{}, result.Error + } + if result.RowsAffected == 0 { + return models.SSHKey{}, gorm.ErrRecordNotFound + } + return key, nil +} + +func (r *SSHKeyRepositoryImpl) Delete(id string) error { + result := r.db.Delete(&models.SSHKey{}, "id = ?", id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *SSHKeyRepositoryImpl) ExistsByFingerprint(fingerprint string) bool { + var count int64 + r.db.Model(&models.SSHKey{}).Where("fingerprint = ?", fingerprint).Count(&count) + return count > 0 +} + +type PATRepository interface { + Create(token *models.PersonalAccessToken) error + FindByID(id string) (models.PersonalAccessToken, error) + FindByUserID(userID string) ([]models.PersonalAccessToken, error) + FindByTokenHash(hash string) (models.PersonalAccessToken, error) + Update(token *models.PersonalAccessToken) error + Delete(id string) error +} + +type PATRepositoryImpl struct { + db *gorm.DB +} + +func NewPATRepository(db *gorm.DB) *PATRepositoryImpl { + return &PATRepositoryImpl{db: db} +} + +func (r *PATRepositoryImpl) Create(token *models.PersonalAccessToken) error { + return r.db.Create(token).Error +} + +func (r *PATRepositoryImpl) FindByID(id string) (models.PersonalAccessToken, error) { + var token models.PersonalAccessToken + result := r.db.First(&token, "id = ?", id) + if result.Error != nil { + return models.PersonalAccessToken{}, result.Error + } + if result.RowsAffected == 0 { + return models.PersonalAccessToken{}, gorm.ErrRecordNotFound + } + return token, nil +} + +func (r *PATRepositoryImpl) FindByUserID(userID string) ([]models.PersonalAccessToken, error) { + var tokens []models.PersonalAccessToken + result := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&tokens) + if result.Error != nil { + return nil, result.Error + } + return tokens, nil +} + +func (r *PATRepositoryImpl) FindByTokenHash(hash string) (models.PersonalAccessToken, error) { + var token models.PersonalAccessToken + result := r.db.Where("token_hash = ?", hash).First(&token) + if result.Error != nil { + return models.PersonalAccessToken{}, result.Error + } + if result.RowsAffected == 0 { + return models.PersonalAccessToken{}, gorm.ErrRecordNotFound + } + return token, nil +} + +func (r *PATRepositoryImpl) Update(token *models.PersonalAccessToken) error { + return r.db.Save(token).Error +} + +func (r *PATRepositoryImpl) Delete(id string) error { + result := r.db.Delete(&models.PersonalAccessToken{}, "id = ?", id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +var _ = errors.New From 2a1c90feb4419720757c33203ba575664d2c8d3f Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:45:23 +0700 Subject: [PATCH 07/49] feat(routes): update AuthMiddleware to accept database transaction --- internal/bootstrap/router.go | 3 +++ modules/githttp/routes.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/bootstrap/router.go b/internal/bootstrap/router.go index 2e4a508..68aa175 100644 --- a/internal/bootstrap/router.go +++ b/internal/bootstrap/router.go @@ -2,6 +2,7 @@ package bootstrap import ( "gitxyz/internal/api/routes" + "gitxyz/modules/githttp" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -11,4 +12,6 @@ func MakeRouter(engine *gin.Engine, db *gorm.DB) { router := routes.NewRoutes(engine, db) router.RegisterAuth() router.RegisterRepositories() + + githttp.RegisterRoutes(engine, db) } diff --git a/modules/githttp/routes.go b/modules/githttp/routes.go index bba84b3..e875f11 100644 --- a/modules/githttp/routes.go +++ b/modules/githttp/routes.go @@ -12,7 +12,7 @@ func RegisterRoutes(server *gin.Engine, tx *gorm.DB) { routes := server.Group("/:username/:reponame") - routes.Use(middlewares.AuthMiddleware()) + routes.Use(middlewares.AuthMiddleware(tx)) routes.Match([]string{"GET"}, "/info/refs", controller.InfoRefs) routes.Match([]string{"POST", "OPTIONS"}, "/git-receive-pack", controller.ReceivePack) From bcebfe70498ae31704ed45ac6c6a668c560b47df Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:46:00 +0700 Subject: [PATCH 08/49] feat(middlewares): enhance AuthMiddleware to validate credentials with user repository and PAT --- modules/githttp/middlewares/auth.go | 56 +++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/modules/githttp/middlewares/auth.go b/modules/githttp/middlewares/auth.go index ebdb071..25276f6 100644 --- a/modules/githttp/middlewares/auth.go +++ b/modules/githttp/middlewares/auth.go @@ -1,23 +1,57 @@ package middlewares import ( + "gitxyz/internal/helper" + "gitxyz/internal/repository" + "github.com/gin-gonic/gin" + "gorm.io/gorm" ) -func validateUser(username string, password string) bool { - // TODO: Implement validate user on auth middleware - return true // placeholder -} +// AuthMiddleware validates Git HTTP Basic credentials. The credentials may be +// either a username + password, or a username + personal access token (PAT). +// On success it stores the resolved username in the context. +func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { + userRepo := repository.NewUserRepository(db) + patRepo := repository.NewPATRepository(db) -func AuthMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { - username, password, ok := ctx.Request.BasicAuth() - if !ok || validateUser(username, password) { - ctx.Header("WWW-Authenticate", "Basic realm=Git") - ctx.AbortWithStatus(401) + username, secret, ok := ctx.Request.BasicAuth() + if !ok { + // No credentials: leave username empty so downstream + // authorization can still allow public, read-only access. + ctx.Set("username", "") + ctx.Next() + return + } + + if validateCredentials(userRepo, patRepo, username, secret) { + ctx.Set("username", username) + ctx.Next() return } - ctx.Set("username", username) - ctx.Next() + + ctx.Header("WWW-Authenticate", `Basic realm="Git"`) + ctx.AbortWithStatus(401) } } + +// validateCredentials checks a username/secret pair against either the user's +// password or a personal access token. +func validateCredentials( + userRepo *repository.UserRepositoryImpl, + patRepo *repository.PATRepositoryImpl, + username, secret string, +) bool { + // 1. Try password login. + if user, err := userRepo.Authenticate(username, secret); err == nil && user.ID != [16]byte{} { + return true + } + + // 2. Try personal access token (hashed lookup). + if _, err := patRepo.FindByTokenHash(helper.HashToken(secret)); err == nil { + return true + } + + return false +} From ee676d7d5a21cb7daf83f516c58ac59db0d94708 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:46:04 +0700 Subject: [PATCH 09/49] feat(services): update authorization logic to use authenticated username for permission checks --- modules/githttp/services/authorize.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/githttp/services/authorize.go b/modules/githttp/services/authorize.go index 71833e0..c008775 100644 --- a/modules/githttp/services/authorize.go +++ b/modules/githttp/services/authorize.go @@ -30,15 +30,20 @@ func (s *GitServiceImpl) Authorize(ctx *gin.Context, options helper.Options) boo permission := NewPermission(s.db) + // Use the authenticated username (set by AuthMiddleware), not the URL owner. + username := ctx.GetString("username") + switch options.ServiceType { case helper.ServiceTypeReceivePack: - if !permission.CanRead(options.UserName, options.RepoName) { - ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "read access denied"}) + // Push requires write permission. + if !permission.CanWrite(username, options.RepoName) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "write access denied"}) return false } case helper.ServiceTypeUploadPack: - if !permission.CanWrite(options.UserName, options.RepoName) { - ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "write access denied"}) + // Clone/fetch requires read permission. + if !permission.CanRead(username, options.RepoName) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "read access denied"}) return false } default: From 8f44a8d03d886e7ae31278b9b94262d779a75025 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:46:08 +0700 Subject: [PATCH 10/49] feat(services): implement permission checks for repository access in CanRead and CanWrite methods --- modules/githttp/services/permission.go | 49 +++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/modules/githttp/services/permission.go b/modules/githttp/services/permission.go index 51a7810..448d551 100644 --- a/modules/githttp/services/permission.go +++ b/modules/githttp/services/permission.go @@ -1,6 +1,10 @@ package services -import "gorm.io/gorm" +import ( + "gitxyz/internal/repository" + + "gorm.io/gorm" +) type Permission interface { CanRead(username string, reponame string) bool @@ -17,12 +21,47 @@ func NewPermission(db *gorm.DB) Permission { } } +// CanRead reports whether username may fetch/clone reponame. +// Public repositories are readable by anyone, including anonymous users +// (username == ""). Private repositories require the owner. func (a *PermissionImpl) CanRead(username string, reponame string) bool { - // TODO: Implement function for authorization repository - return true // placeholder + repoRepo := repository.NewRepoRepository(a.db) + repo, err := repoRepo.FindByName(reponame) + if err != nil { + return false + } + + if !repo.IsPrivate { + return true + } + + // Private: only the owner may read. + return username != "" && repo.UserID != "" && ownerMatches(a.db, username, repo.UserID) } +// CanWrite reports whether username may push to reponame. +// Per project rules, public repositories accept pushes from anyone +// (including anonymous users). Private repositories require the owner. func (a *PermissionImpl) CanWrite(username string, reponame string) bool { - // TODO: Implement function for authorization repository - return true // placeholder + repoRepo := repository.NewRepoRepository(a.db) + repo, err := repoRepo.FindByName(reponame) + if err != nil { + return false + } + + if !repo.IsPrivate { + return true + } + + return username != "" && repo.UserID != "" && ownerMatches(a.db, username, repo.UserID) +} + +// ownerMatches resolves username to its user ID and compares with repo.UserID. +func ownerMatches(db *gorm.DB, username, repoUserID string) bool { + userRepo := repository.NewUserRepository(db) + user, err := userRepo.FindByUsername(username) + if err != nil { + return false + } + return user.ID.String() == repoUserID } From b73686f5683a1fbea473e3e00b9445df7789cdf6 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 11:46:32 +0700 Subject: [PATCH 11/49] fix(helper): correct hash generation in GenerateRepositoryPath function --- internal/helper/path.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/helper/path.go b/internal/helper/path.go index 72f25be..c6d9c1b 100644 --- a/internal/helper/path.go +++ b/internal/helper/path.go @@ -10,7 +10,7 @@ import ( ) func GenerateRepositoryPath() string { - hash := sha512.Sum512([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + hash := sha512.Sum512(fmt.Appendf(nil, "%d", time.Now().UnixNano())) hashStr := fmt.Sprintf("%x", hash)[:16] volumePath := viper.GetString("volume_path") From 65fe899b6f0c68441088faefb6334c0f02dfe8e9 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 12:14:12 +0700 Subject: [PATCH 12/49] docs(repo.go): remove redundant comments from RepositoryResponse struct and ToRepositoryResponse function --- internal/api/dto/response/repo.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/api/dto/response/repo.go b/internal/api/dto/response/repo.go index 13efc8d..e5e9ba3 100644 --- a/internal/api/dto/response/repo.go +++ b/internal/api/dto/response/repo.go @@ -5,9 +5,6 @@ import ( "time" ) -// RepositoryResponse is the safe, filtered projection of a Repository that is -// returned to API clients. It intentionally omits the nested User association -// and GORM internals (e.g. DeletedAt) to avoid leaking zero-value structs. type RepositoryResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -20,8 +17,6 @@ type RepositoryResponse struct { UpdatedAt time.Time `json:"updated_at"` } -// ToRepositoryResponse builds the response. owner is the repository path -// prefix (typically the owner's username), e.g. "gitxyz" for "gitxyz/gitxyz". func ToRepositoryResponse(repo *models.Repository, owner string) RepositoryResponse { fullName := repo.Name if owner != "" { From 68c1968c9734e2ec8f7129f6003c6e59470035a1 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 12:14:48 +0700 Subject: [PATCH 13/49] refactor(db): drop physical_path column and unique constraint from repositories --- db/migrations/000004_drop_repositories_physical_path.down.sql | 2 ++ db/migrations/000004_drop_repositories_physical_path.up.sql | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 db/migrations/000004_drop_repositories_physical_path.down.sql create mode 100644 db/migrations/000004_drop_repositories_physical_path.up.sql diff --git a/db/migrations/000004_drop_repositories_physical_path.down.sql b/db/migrations/000004_drop_repositories_physical_path.down.sql new file mode 100644 index 0000000..fe38829 --- /dev/null +++ b/db/migrations/000004_drop_repositories_physical_path.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE repositories ADD COLUMN physical_path VARCHAR NOT NULL DEFAULT ''; +ALTER TABLE repositories ADD CONSTRAINT repositories_physical_path_key UNIQUE (physical_path); diff --git a/db/migrations/000004_drop_repositories_physical_path.up.sql b/db/migrations/000004_drop_repositories_physical_path.up.sql new file mode 100644 index 0000000..527d7a4 --- /dev/null +++ b/db/migrations/000004_drop_repositories_physical_path.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE repositories DROP CONSTRAINT IF EXISTS repositories_physical_path_key; +ALTER TABLE repositories DROP COLUMN IF EXISTS physical_path; From 0d0033f329f553685f926f703248a96adbfe7bf4 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 12:18:24 +0700 Subject: [PATCH 14/49] refactor(githttp): derive repository storage path from repo ID instead of user/repo name refactor(githttp): inject gorm DB into options and remove viper dependency feat(githttp): resolve repo ID in auth middleware and pass it to services fix(githttp): return early on storage ensure error in GetInfoRefs refactor(githttp): simplify auth middleware control flow and error handling --- modules/githttp/helper/options.go | 30 +++++++++++---------- modules/githttp/middlewares/auth.go | 33 +++++++++++++----------- modules/githttp/services/info-refs.go | 19 ++++++++++---- modules/githttp/services/receive-pack.go | 10 +++++-- 4 files changed, 56 insertions(+), 36 deletions(-) diff --git a/modules/githttp/helper/options.go b/modules/githttp/helper/options.go index 83cacae..6b2406e 100644 --- a/modules/githttp/helper/options.go +++ b/modules/githttp/helper/options.go @@ -5,8 +5,10 @@ import ( "path/filepath" "strings" + "gitxyz/internal/helper" + "github.com/gin-gonic/gin" - "github.com/spf13/viper" + "gorm.io/gorm" ) type ServiceType string @@ -15,6 +17,8 @@ type Options struct { ServiceType ServiceType RepoName string UserName string + + db *gorm.DB } const ( @@ -23,9 +27,11 @@ const ( ServiceTypeUploadArchive = "upload-archive" ) -func MakeOptionsFromContext(ctx *gin.Context) Options { +func MakeOptionsFromContext(ctx *gin.Context, db *gorm.DB) Options { // initialize options - options := &Options{} + options := &Options{ + db: db, + } // get service type service := ctx.Query("service") @@ -52,20 +58,16 @@ func MakeOptionsFromContext(ctx *gin.Context) Options { return *options } -func (o *Options) GetRepositoryStorage() string { +// GetRepositoryStorage resolves the absolute on-disk location of a repository +// from its ID: "wd/volume_path/". The path is deterministic and derived +// from the repo ID alone — no physical_path column is stored. +func (o *Options) GetRepositoryStorage(repoID string) string { wd, _ := os.Getwd() - volumePath := viper.GetString("volume_path") - - return filepath.Join( - wd, - volumePath, - o.UserName, - o.RepoName, - ) + return filepath.Join(wd, helper.RepositoryStoragePath(repoID)) } -func (o *Options) EnsureRepositoryStorage() (string, error) { - path := o.GetRepositoryStorage() +func (o *Options) EnsureRepositoryStorage(repoID string) (string, error) { + path := o.GetRepositoryStorage(repoID) err := os.MkdirAll(path, 0755) // 0755 = rwxr-xr-x if err != nil { return "", err diff --git a/modules/githttp/middlewares/auth.go b/modules/githttp/middlewares/auth.go index 25276f6..5a180ae 100644 --- a/modules/githttp/middlewares/auth.go +++ b/modules/githttp/middlewares/auth.go @@ -3,52 +3,55 @@ package middlewares import ( "gitxyz/internal/helper" "gitxyz/internal/repository" + githttpHelper "gitxyz/modules/githttp/helper" + "net/http" "github.com/gin-gonic/gin" "gorm.io/gorm" ) -// AuthMiddleware validates Git HTTP Basic credentials. The credentials may be -// either a username + password, or a username + personal access token (PAT). -// On success it stores the resolved username in the context. func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { userRepo := repository.NewUserRepository(db) patRepo := repository.NewPATRepository(db) + repoRepo := repository.NewRepoRepository(db) return func(ctx *gin.Context) { username, secret, ok := ctx.Request.BasicAuth() if !ok { - // No credentials: leave username empty so downstream - // authorization can still allow public, read-only access. ctx.Set("username", "") - ctx.Next() + } else if validateCredentials(userRepo, patRepo, username, secret) { + ctx.Set("username", username) + } else { + ctx.Header("WWW-Authenticate", `Basic realm="Git"`) + ctx.AbortWithStatus(http.StatusUnauthorized) return } - if validateCredentials(userRepo, patRepo, username, secret) { - ctx.Set("username", username) - ctx.Next() + options := githttpHelper.MakeOptionsFromContext(ctx, db) + repo, err := repoRepo.FindByName(options.RepoName) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{ + "error": "repository not found", + }) return } + // The on-disk path is derived from the repo ID at runtime + // (volume_path/), so we only need to pass the ID forward. + ctx.Set("repo_id", repo.ID.String()) - ctx.Header("WWW-Authenticate", `Basic realm="Git"`) - ctx.AbortWithStatus(401) + ctx.Next() } } -// validateCredentials checks a username/secret pair against either the user's -// password or a personal access token. func validateCredentials( userRepo *repository.UserRepositoryImpl, patRepo *repository.PATRepositoryImpl, username, secret string, ) bool { - // 1. Try password login. if user, err := userRepo.Authenticate(username, secret); err == nil && user.ID != [16]byte{} { return true } - // 2. Try personal access token (hashed lookup). if _, err := patRepo.FindByTokenHash(helper.HashToken(secret)); err == nil { return true } diff --git a/modules/githttp/services/info-refs.go b/modules/githttp/services/info-refs.go index 5a1334f..ab20c54 100644 --- a/modules/githttp/services/info-refs.go +++ b/modules/githttp/services/info-refs.go @@ -2,6 +2,8 @@ package services import ( "fmt" + "net/http" + "gitxyz/modules/githttp/helper" "gitxyz/pkg/git" @@ -9,11 +11,18 @@ import ( ) func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { - options := helper.MakeOptionsFromContext(ctx) + options := helper.MakeOptionsFromContext(ctx, s.db) - physical_path, err := options.EnsureRepositoryStorage() + repoID := ctx.GetString("repo_id") + if repoID == "" { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + + storagePath, err := options.EnsureRepositoryStorage(repoID) if err != nil { ctx.AbortWithError(500, err) + return } authorized := s.Authorize(ctx, options) @@ -22,20 +31,20 @@ func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { } cmd := git.NewCommand() - isBareRepository, err := cmd.IsBareRepository(physical_path) + isBareRepository, err := cmd.IsBareRepository(storagePath) if err != nil { ctx.AbortWithError(500, err) return } if !isBareRepository { - if _, err := cmd.InitBare(physical_path); err != nil { + if _, err := cmd.InitBare(storagePath); err != nil { ctx.AbortWithError(500, err) return } } - refs, err := cmd.ReceivePack(physical_path) + refs, err := cmd.ReceivePack(storagePath) if err != nil { ctx.AbortWithError(500, err) return diff --git a/modules/githttp/services/receive-pack.go b/modules/githttp/services/receive-pack.go index 59f0cb0..2650812 100644 --- a/modules/githttp/services/receive-pack.go +++ b/modules/githttp/services/receive-pack.go @@ -12,12 +12,18 @@ import ( func (s *GitServiceImpl) ReceivePack(ctx *gin.Context) { log.Println(">>> ReceivePack handler") - options := helper.MakeOptionsFromContext(ctx) + options := helper.MakeOptionsFromContext(ctx, s.db) + + repoID := ctx.GetString("repo_id") + if repoID == "" { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } ctx.Header("Content-Type", "application/x-git-receive-pack-result") cmd := git.NewCommand() - err := cmd.ReceivePackRPC(options.GetRepositoryStorage(), ctx.Request.Body, ctx.Writer) + err := cmd.ReceivePackRPC(options.GetRepositoryStorage(repoID), ctx.Request.Body, ctx.Writer) if err != nil { ctx.AbortWithError(http.StatusInternalServerError, err) return From fbdecf82ffc50b093fb302affd997a9be6d9382a Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 12:20:49 +0700 Subject: [PATCH 15/49] refactor(repo): remove physical_path column and related methods, derive path from repo ID refactor(auth): clean up user and auth route definitions refactor(helper): rename GenerateRepositoryPath to RepositoryStoragePath for clarity fix(repo_test): update test to check for repo ID assignment instead of physical path --- internal/api/routes/auth.go | 15 ++++++--------- internal/api/services/repo.go | 5 ++--- internal/api/services/repo_test.go | 4 ++-- internal/api/services/user.go | 2 +- internal/helper/path.go | 14 ++++++-------- internal/models/repo.go | 9 ++++----- internal/repository/repo.go | 7 ------- 7 files changed, 21 insertions(+), 35 deletions(-) diff --git a/internal/api/routes/auth.go b/internal/api/routes/auth.go index e56f776..cf8664b 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -9,7 +9,6 @@ func (r *RoutesImpl) RegisterAuth() { authController := controllers.NewAuthController(r.db) userController := controllers.NewUserController(r.db) - // --- Auth routes (ENDPOINT.md §2) --- auth := r.engine.Group("/api/auth") public := auth.Group("/") protected := auth.Group("/") @@ -26,22 +25,20 @@ func (r *RoutesImpl) RegisterAuth() { protected.POST("/logout", authController.Logout) protected.GET("/me", authController.Profile) - // --- User routes (ENDPOINT.md §2) --- user := r.engine.Group("/api/user") user.Use(middlewares.AuthRequired()) - user.GET("", authController.Profile) // GET /user — profil sendiri - user.PATCH("", authController.UpdateProfile) // PATCH /user + user.GET("", authController.Profile) + user.PATCH("", authController.UpdateProfile) user.POST("/change-password", authController.ChangePassword) - user.GET("/keys", userController.ListSSHKeys) // GET /user/keys - user.POST("/keys", userController.AddSSHKey) // POST /user/keys + user.GET("/keys", userController.ListSSHKeys) + user.POST("/keys", userController.AddSSHKey) user.DELETE("/keys/:id", userController.DeleteSSHKey) - user.GET("/tokens", userController.ListTokens) // GET /user/tokens - user.POST("/tokens", userController.CreateToken) // POST /user/tokens + user.GET("/tokens", userController.ListTokens) + user.POST("/tokens", userController.CreateToken) user.DELETE("/tokens/:id", userController.DeleteToken) - // Public user lookup r.engine.GET("/api/users/:username", authController.GetUserByUsername) } diff --git a/internal/api/services/repo.go b/internal/api/services/repo.go index 616780f..e14f7f2 100644 --- a/internal/api/services/repo.go +++ b/internal/api/services/repo.go @@ -2,7 +2,6 @@ package services import ( "errors" - "gitxyz/internal/helper" "gitxyz/internal/models" "gitxyz/internal/repository" @@ -32,7 +31,7 @@ func (s *RepoServiceImpl) CreateRepository(repo *models.Repository) error { return errors.New("user id is required") } - repo.PhysicalPath = helper.GenerateRepositoryPath() - + // The on-disk path is derived from repo.ID at runtime (volume_path/), + // so no physical_path column is stored. return s.Repository.Create(repo) } diff --git a/internal/api/services/repo_test.go b/internal/api/services/repo_test.go index fee8b5a..2bf445e 100644 --- a/internal/api/services/repo_test.go +++ b/internal/api/services/repo_test.go @@ -44,7 +44,7 @@ func TestCreateRepositorySuccess(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if repo.PhysicalPath == "" { - t.Fatal("physical path should be generated") + if repo.ID == [16]byte{} { + t.Fatal("repo ID should be assigned by the model hook") } } diff --git a/internal/api/services/user.go b/internal/api/services/user.go index e1ec74c..f58c36a 100644 --- a/internal/api/services/user.go +++ b/internal/api/services/user.go @@ -99,7 +99,7 @@ func (s *UserServiceImpl) CreateToken(userID, name, scopes string) (models.Perso if err := s.Tokens.Create(token); err != nil { return models.PersonalAccessToken{}, "", err } - // Return the plaintext token only once, at creation time. + return *token, plain, nil } diff --git a/internal/helper/path.go b/internal/helper/path.go index c6d9c1b..34afd9e 100644 --- a/internal/helper/path.go +++ b/internal/helper/path.go @@ -1,18 +1,16 @@ package helper import ( - "crypto/sha512" - "fmt" "path/filepath" - "time" "github.com/spf13/viper" ) -func GenerateRepositoryPath() string { - hash := sha512.Sum512(fmt.Appendf(nil, "%d", time.Now().UnixNano())) - hashStr := fmt.Sprintf("%x", hash)[:16] - +// RepositoryStoragePath returns the on-disk location of a repository relative +// to the project root, derived from its ID: "volume_path/". +// The path is deterministic (no random hash) so it can be rebuilt from the +// repo ID alone — no physical_path column is stored. +func RepositoryStoragePath(repoID string) string { volumePath := viper.GetString("volume_path") - return filepath.Join(volumePath, hashStr) + return filepath.Join(volumePath, repoID) } diff --git a/internal/models/repo.go b/internal/models/repo.go index 30beea2..0f2c4bc 100644 --- a/internal/models/repo.go +++ b/internal/models/repo.go @@ -3,11 +3,10 @@ package models type Repository struct { Base - Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` - Description string `json:"description" gorm:"type:text"` - PhysicalPath string `json:"physical_path" gorm:"not null;uniqueIndex"` - IsPrivate bool `json:"is_private" gorm:"not null;default:false"` - IsActive bool `json:"is_active" gorm:"not null;default:true"` + Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` + Description string `json:"description" gorm:"type:text"` + IsPrivate bool `json:"is_private" gorm:"not null;default:false"` + IsActive bool `json:"is_active" gorm:"not null;default:true"` UserID string `json:"user_id" gorm:"not null"` User User `json:"user" gorm:"foreignKey:UserID;references:ID"` diff --git a/internal/repository/repo.go b/internal/repository/repo.go index 3d8539b..50a44ef 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -10,7 +10,6 @@ import ( type RepoRepository interface { Create(repo *models.Repository) error FindById(id string) (repo models.Repository, err error) - FindByPhysicalPath(path string) (repo models.Repository, err error) FindByName(name string) (repo models.Repository, err error) ExistsByName(name string) bool } @@ -50,12 +49,6 @@ func (r *RepoRepositoryImpl) FindById(id string) (repo models.Repository, err er return check(result, &repo) } -func (r *RepoRepositoryImpl) FindByPhysicalPath(path string) (repo models.Repository, err error) { - result := r.db.Where("physical_path = ?", path).First(&repo) - - return check(result, &repo) -} - func (r *RepoRepositoryImpl) FindByName(name string) (repo models.Repository, err error) { result := r.db.Where("name = ?", name).First(&repo) From 9fc04f093d5d2345ae914d49bcd786f595909b18 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:17:22 +0700 Subject: [PATCH 16/49] feat(migrations): add user roles and repository members tables with constraints --- db/migrations/000005_add_user_role.down.sql | 2 ++ db/migrations/000005_add_user_role.up.sql | 6 ++++++ .../000006_create_repository_members.down.sql | 1 + .../000006_create_repository_members.up.sql | 14 ++++++++++++++ db/migrations/000007_create_policies.down.sql | 1 + db/migrations/000007_create_policies.up.sql | 15 +++++++++++++++ 6 files changed, 39 insertions(+) create mode 100644 db/migrations/000005_add_user_role.down.sql create mode 100644 db/migrations/000005_add_user_role.up.sql create mode 100644 db/migrations/000006_create_repository_members.down.sql create mode 100644 db/migrations/000006_create_repository_members.up.sql create mode 100644 db/migrations/000007_create_policies.down.sql create mode 100644 db/migrations/000007_create_policies.up.sql diff --git a/db/migrations/000005_add_user_role.down.sql b/db/migrations/000005_add_user_role.down.sql new file mode 100644 index 0000000..6a5d4ec --- /dev/null +++ b/db/migrations/000005_add_user_role.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_user_role; +ALTER TABLE users DROP COLUMN IF EXISTS role; diff --git a/db/migrations/000005_add_user_role.up.sql b/db/migrations/000005_add_user_role.up.sql new file mode 100644 index 0000000..8148803 --- /dev/null +++ b/db/migrations/000005_add_user_role.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE users + ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user'; + +ALTER TABLE users + ADD CONSTRAINT chk_user_role + CHECK (role IN ('owner', 'admin', 'maintainer', 'user', 'guest')); diff --git a/db/migrations/000006_create_repository_members.down.sql b/db/migrations/000006_create_repository_members.down.sql new file mode 100644 index 0000000..a592061 --- /dev/null +++ b/db/migrations/000006_create_repository_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS repository_members; diff --git a/db/migrations/000006_create_repository_members.up.sql b/db/migrations/000006_create_repository_members.up.sql new file mode 100644 index 0000000..8f4d0c1 --- /dev/null +++ b/db/migrations/000006_create_repository_members.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE repository_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, repo_id), + CONSTRAINT chk_repo_member_role + CHECK (role IN ('owner', 'maintainer', 'triager', 'reader', 'guest')) +); + +CREATE INDEX idx_repo_members_repo_id ON repository_members(repo_id); +CREATE INDEX idx_repo_members_user_id ON repository_members(user_id); diff --git a/db/migrations/000007_create_policies.down.sql b/db/migrations/000007_create_policies.down.sql new file mode 100644 index 0000000..e67d8d8 --- /dev/null +++ b/db/migrations/000007_create_policies.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS policies; diff --git a/db/migrations/000007_create_policies.up.sql b/db/migrations/000007_create_policies.up.sql new file mode 100644 index 0000000..106220b --- /dev/null +++ b/db/migrations/000007_create_policies.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subject_type VARCHAR(20) NOT NULL, + subject_id VARCHAR(255) NOT NULL, + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(20) NOT NULL, + resource_id VARCHAR(255) NOT NULL DEFAULT '*', + effect VARCHAR(10) NOT NULL DEFAULT 'allow', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(subject_type, subject_id, action, resource_type, resource_id), + CONSTRAINT chk_policy_effect CHECK (effect IN ('allow', 'deny')) +); + +CREATE INDEX idx_policies_lookup + ON policies(subject_type, subject_id, action, resource_type, resource_id); From b74f2bb6b883701d3ee2d406d2bd351327b05ae0 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:17:33 +0700 Subject: [PATCH 17/49] feat(rbac): implement role-based access control middleware and tests --- internal/api/middlewares/auth.go | 1 + internal/api/middlewares/rbac.go | 79 +++++++++++ internal/api/middlewares/rbac_test.go | 190 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 internal/api/middlewares/rbac.go create mode 100644 internal/api/middlewares/rbac_test.go diff --git a/internal/api/middlewares/auth.go b/internal/api/middlewares/auth.go index 93689fe..84b4837 100644 --- a/internal/api/middlewares/auth.go +++ b/internal/api/middlewares/auth.go @@ -32,6 +32,7 @@ func AuthRequired() gin.HandlerFunc { ctx.Set("user_id", claims.UserID) ctx.Set("username", claims.Username) ctx.Set("email", claims.Email) + ctx.Set("role", claims.Role) ctx.Next() } } diff --git a/internal/api/middlewares/rbac.go b/internal/api/middlewares/rbac.go new file mode 100644 index 0000000..3681975 --- /dev/null +++ b/internal/api/middlewares/rbac.go @@ -0,0 +1,79 @@ +package middlewares + +import ( + "net/http" + + "gitxyz/internal/api/services" + "gitxyz/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RequireRole aborts with 403 unless the context role is in allowed. +func RequireRole(allowed ...string) gin.HandlerFunc { + return func(ctx *gin.Context) { + role := ctx.GetString("role") + for _, a := range allowed { + if role == a { + ctx.Next() + return + } + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"}) + } +} + +// RequireScope aborts with 403 unless the credential covers scope. +// - PAT auth: must carry the scope (or admin:*). +// - JWT auth: allowed only if the user's system role is equivalent +// (admin/owner grant everything; maintainer grants user-scoped scopes). +func RequireScope(db *gorm.DB, scope string) gin.HandlerFunc { + return func(ctx *gin.Context) { + if ctx.GetString("token_type") == "pat" { + scopes := models.ParseScopes(ctx.GetString("token_scopes")) + for _, s := range scopes { + if s == scope || s == models.ScopeAdmin { + ctx.Next() + return + } + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient token scope"}) + return + } + + // JWT user auth: role-equivalence check. + role := ctx.GetString("role") + if role == models.RoleAdmin || role == models.RoleOwner { + ctx.Next() + return + } + // maintainer may perform user-scoped actions. + if role == models.RoleMaintainer && + (scope == models.ScopeUserRead || scope == models.ScopeUserWrite) { + ctx.Next() + return + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role for scope"}) + } +} + +// CheckPolicy evaluates ABAC for a specific resource action. +func CheckPolicy(db *gorm.DB, action, resourceType, resourceID string) gin.HandlerFunc { + return func(ctx *gin.Context) { + role := ctx.GetString("role") + userID := ctx.GetString("user_id") + + if ok, err := services.EvaluatePolicy(db, "role", role, action, resourceType, resourceID); err == nil && ok { + ctx.Next() + return + } + if userID != "" { + if ok, err := services.EvaluatePolicy(db, "user", userID, action, resourceType, resourceID); err == nil && ok { + ctx.Next() + return + } + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "policy denied"}) + } +} diff --git a/internal/api/middlewares/rbac_test.go b/internal/api/middlewares/rbac_test.go new file mode 100644 index 0000000..785d981 --- /dev/null +++ b/internal/api/middlewares/rbac_test.go @@ -0,0 +1,190 @@ +package middlewares + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestRequireRoleAllows(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("role", "admin"); c.Next() }) + r.Use(RequireRole("admin", "owner")) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} + +func TestRequireRoleDenies(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("role", "user"); c.Next() }) + r.Use(RequireRole("admin", "owner")) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("want 403 got %d", w.Code) + } +} + +func newScopeTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.Policy{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + return db +} + +func TestRequireScopePATAllows(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("token_type", "pat") + c.Set("token_scopes", "repo:write") + c.Next() + }) + r.Use(RequireScope(db, models.ScopeRepoWrite)) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} + +func TestRequireScopePATDenies(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("token_type", "pat") + c.Set("token_scopes", "repo:read") + c.Next() + }) + r.Use(RequireScope(db, models.ScopeRepoWrite)) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("want 403 got %d", w.Code) + } +} + +func TestRequireScopeJWTUserDenies(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("token_type", "jwt") + c.Set("role", "user") + c.Next() + }) + r.Use(RequireScope(db, models.ScopeRepoWrite)) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("want 403 got %d", w.Code) + } +} + +func TestRequireScopeJWTAdminAllows(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("token_type", "jwt") + c.Set("role", "admin") + c.Next() + }) + r.Use(RequireScope(db, models.ScopeRepoWrite)) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} + +func TestCheckPolicyDenyWins(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + polRepo := repository.NewPolicyRepository(db) + if err := polRepo.Add(&models.Policy{ + SubjectType: "user", SubjectID: "u1", Action: "repo:read", + ResourceType: "repository", ResourceID: "r1", Effect: "deny", + }); err != nil { + t.Fatalf("add policy: %v", err) + } + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Set("user_id", "u1") + c.Next() + }) + r.Use(CheckPolicy(db, "repo:read", "repository", "r1")) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("want 403 got %d", w.Code) + } +} + +func TestCheckPolicyUserAllow(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newScopeTestDB(t) + polRepo := repository.NewPolicyRepository(db) + if err := polRepo.Add(&models.Policy{ + SubjectType: "user", SubjectID: "u1", Action: "repo:read", + ResourceType: "repository", ResourceID: "r1", Effect: "allow", + }); err != nil { + t.Fatalf("add policy: %v", err) + } + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Set("user_id", "u1") + c.Next() + }) + r.Use(CheckPolicy(db, "repo:read", "repository", "r1")) + r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} From 6cfb667a3836cc9b62fdab4366253a0d962a6458 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:17:44 +0700 Subject: [PATCH 18/49] feat(rbac): implement EvaluatePolicy function and related tests for role-based access control --- internal/api/auth/jwt.go | 2 + internal/api/routes/auth.go | 8 +++- internal/api/services/rbac.go | 50 ++++++++++++++++++++++++ internal/api/services/repo_test.go | 63 ++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 internal/api/services/rbac.go diff --git a/internal/api/auth/jwt.go b/internal/api/auth/jwt.go index 134b233..7e05c76 100644 --- a/internal/api/auth/jwt.go +++ b/internal/api/auth/jwt.go @@ -16,6 +16,7 @@ type Claims struct { UserID string `json:"user_id"` Username string `json:"username"` Email string `json:"email"` + Role string `json:"role"` TokenType string `json:"token_type"` jwt.RegisteredClaims } @@ -59,6 +60,7 @@ func GenerateToken(user *models.User, tokenType string) (string, error) { UserID: user.ID.String(), Username: user.Username, Email: user.Email, + Role: user.Role, TokenType: tokenType, RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), diff --git a/internal/api/routes/auth.go b/internal/api/routes/auth.go index cf8664b..fc1175d 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -3,6 +3,7 @@ package routes import ( "gitxyz/internal/api/controllers" "gitxyz/internal/api/middlewares" + "gitxyz/internal/models" ) func (r *RoutesImpl) RegisterAuth() { @@ -36,9 +37,14 @@ func (r *RoutesImpl) RegisterAuth() { user.POST("/keys", userController.AddSSHKey) user.DELETE("/keys/:id", userController.DeleteSSHKey) + // Token management requires a PAT-capable credential (or admin/owner role). + user.Use(middlewares.RequireScope(r.db, models.ScopeUserWrite)) user.GET("/tokens", userController.ListTokens) user.POST("/tokens", userController.CreateToken) user.DELETE("/tokens/:id", userController.DeleteToken) - r.engine.GET("/api/users/:username", authController.GetUserByUsername) + // Admin-only user lookup. + admin := r.engine.Group("/api/users") + admin.Use(middlewares.AuthRequired(), middlewares.RequireRole(models.RoleAdmin, models.RoleOwner)) + admin.GET("/:username", authController.GetUserByUsername) } diff --git a/internal/api/services/rbac.go b/internal/api/services/rbac.go new file mode 100644 index 0000000..7f5970c --- /dev/null +++ b/internal/api/services/rbac.go @@ -0,0 +1,50 @@ +package services + +import ( + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "gorm.io/gorm" +) + +// EvaluatePolicy checks explicit ABAC policies first, then falls back to +// role-based rules. Returns true if action is allowed. An error is returned +// if the policy lookup fails (caller must decide; default deny). +func EvaluatePolicy( + db *gorm.DB, + subjectType, subjectID, action, resourceType, resourceID string, +) (bool, error) { + polRepo := repository.NewPolicyRepository(db) + + // Explicit deny wins. + policies, err := polRepo.FindApplicable(subjectType, subjectID, action, resourceType, resourceID) + if err != nil { + return false, err + } + for _, p := range policies { + if p.Effect == "deny" { + return false, nil + } + } + for _, p := range policies { + if p.Effect == "allow" { + return true, nil + } + } + + // Fallback: system admin/owner bypass everything. + if subjectType == "role" { + if subjectID == models.RoleAdmin || subjectID == models.RoleOwner { + return true, nil + } + } + // A user subject whose role is admin/owner also bypasses. + if subjectType == "user" && subjectID != "" { + userRepo := repository.NewUserRepository(db) + user, err := userRepo.FindByID(subjectID) + if err == nil && (user.Role == models.RoleAdmin || user.Role == models.RoleOwner) { + return true, nil + } + } + return false, nil +} diff --git a/internal/api/services/repo_test.go b/internal/api/services/repo_test.go index 2bf445e..ef03ec3 100644 --- a/internal/api/services/repo_test.go +++ b/internal/api/services/repo_test.go @@ -27,6 +27,69 @@ func TestCreateRepositoryValidation(t *testing.T) { } } +func TestEvaluatePolicyDenyWins(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.Policy{}); err != nil { + t.Fatalf("migrate: %v", err) + } + polRepo := repository.NewPolicyRepository(db) + if err := polRepo.Add(&models.Policy{ + SubjectType: "user", SubjectID: "u1", Action: "repo:read", + ResourceType: "repository", ResourceID: "r1", Effect: "deny", + }); err != nil { + t.Fatalf("add: %v", err) + } + ok, err := EvaluatePolicy(db, "user", "u1", "repo:read", "repository", "r1") + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if ok { + t.Error("expected deny to win") + } +} + +func TestEvaluatePolicyAdminBypass(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.Policy{}); err != nil { + t.Fatalf("migrate: %v", err) + } + ok, err := EvaluatePolicy(db, "role", "admin", "repo:write", "repository", "r1") + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if !ok { + t.Error("expected admin role to bypass") + } +} + +func TestEvaluatePolicyUserAdminBypass(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.Policy{}); err != nil { + t.Fatalf("migrate: %v", err) + } + userRepo := repository.NewUserRepository(db) + if err := userRepo.Create(&models.User{Username: "admin1", Email: "a@e.com", Role: "admin"}); err != nil { + t.Fatalf("create user: %v", err) + } + u, _ := userRepo.FindByUsername("admin1") + ok, err := EvaluatePolicy(db, "user", u.ID.String(), "repo:write", "repository", "r1") + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if !ok { + t.Error("expected admin user to bypass") + } +} + func TestCreateRepositorySuccess(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { From 41641aaf33933ea9983c24ab1eccfa9acf14b9c9 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:17:55 +0700 Subject: [PATCH 19/49] feat(rbac): add role and policy models with associated methods and tests --- internal/models/base.go | 2 +- internal/models/keys.go | 28 +++++++++++++++++++++++++--- internal/models/keys_test.go | 16 ++++++++++++++++ internal/models/policy.go | 28 ++++++++++++++++++++++++++++ internal/models/repo_member.go | 26 ++++++++++++++++++++++++++ internal/models/user.go | 11 ++++++++++- 6 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 internal/models/keys_test.go create mode 100644 internal/models/policy.go create mode 100644 internal/models/repo_member.go diff --git a/internal/models/base.go b/internal/models/base.go index 986a211..bf04586 100644 --- a/internal/models/base.go +++ b/internal/models/base.go @@ -11,7 +11,7 @@ type Base struct { ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } func (b *Base) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/models/keys.go b/internal/models/keys.go index f65b342..2cc731c 100644 --- a/internal/models/keys.go +++ b/internal/models/keys.go @@ -1,9 +1,8 @@ package models import ( + "strings" "time" - - "github.com/google/uuid" ) // SSHKey represents a user's registered SSH public key, used for Git over SSH @@ -38,4 +37,27 @@ func (SSHKey) TableName() string { return "ssh_keys" } func (PersonalAccessToken) TableName() string { return "personal_access_tokens" } -var _ = uuid.New +// PAT scope constants. +const ( + ScopeRepoRead = "repo:read" + ScopeRepoWrite = "repo:write" + ScopeUserRead = "user:read" + ScopeUserWrite = "user:write" + ScopeAdmin = "admin:*" +) + +// ParseScopes splits a comma-separated scope string into a trimmed slice. +func ParseScopes(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/models/keys_test.go b/internal/models/keys_test.go new file mode 100644 index 0000000..7451c26 --- /dev/null +++ b/internal/models/keys_test.go @@ -0,0 +1,16 @@ +package models + +import "testing" + +func TestParseScopes(t *testing.T) { + got := ParseScopes("repo:read,repo:write, admin:*") + want := []string{"repo:read", "repo:write", "admin:*"} + if len(got) != len(want) { + t.Fatalf("expected %d scopes, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("scope %d: want %q got %q", i, want[i], got[i]) + } + } +} diff --git a/internal/models/policy.go b/internal/models/policy.go new file mode 100644 index 0000000..7d8ab6e --- /dev/null +++ b/internal/models/policy.go @@ -0,0 +1,28 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Policy struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + SubjectType string `json:"subject_type" gorm:"size:20;not null"` + SubjectID string `json:"subject_id" gorm:"size:255; not null"` + Action string `json:"action" gorm:"size:50; not null"` + ResourceType string `json:"resource_type" gorm:"size:20; not null"` + ResourceID string `json:"resource_id" gorm:"size:255; not null;default:'*'"` + Effect string `json:"effect" gorm:"size:10; not null;default:'allow'"` + CreatedAt time.Time `json:"created_at"` +} + +func (p *Policy) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} + +func (Policy) TableName() string { return "policies" } diff --git a/internal/models/repo_member.go b/internal/models/repo_member.go new file mode 100644 index 0000000..6e553e6 --- /dev/null +++ b/internal/models/repo_member.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type RepositoryMember struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + UserID string `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:uniq_repo_member"` + RepoID string `json:"repo_id" gorm:"type:uuid;not null;uniqueIndex:uniq_repo_member"` + Role string `json:"role" gorm:"size:20;not null"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +func (m *RepositoryMember) BeforeCreate(tx *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + return nil +} + +func (RepositoryMember) TableName() string { return "repository_members" } diff --git a/internal/models/user.go b/internal/models/user.go index 3bbfce7..1fcd877 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -15,7 +15,7 @@ type User struct { Username string `json:"username" gorm:"sze:255;not null;unique"` Email string `json:"email" gorm:"uniqueIndex;not null"` Password string `json:"-" gorm:"size:255"` - + Role string `json:"role" gorm:"size:20;not null;default:user"` IsActive bool `json:"is_active" gorm:"index;not null"` Avatar string `json:"avatar" gorm:"not null"` Bio string `json:"bio"` @@ -24,6 +24,15 @@ type User struct { LastLoginAt time.Time `json:"last_login_at"` } +// System role constants. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMaintainer = "maintainer" + RoleUser = "user" + RoleGuest = "guest" +) + func (u *User) BeforeCreate(tx *gorm.DB) error { if err := u.Base.BeforeCreate(tx); err != nil { return err From 3aad0e939d051f2ea65efab4e8cd2908d9881bb1 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:18:06 +0700 Subject: [PATCH 20/49] feat(rbac): add PolicyRepository and RepoMemberRepository implementations with tests --- internal/repository/policy.go | 40 +++++++++++ internal/repository/repo_member.go | 50 +++++++++++++ internal/repository/repo_member_test.go | 93 +++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 internal/repository/policy.go create mode 100644 internal/repository/repo_member.go create mode 100644 internal/repository/repo_member_test.go diff --git a/internal/repository/policy.go b/internal/repository/policy.go new file mode 100644 index 0000000..ce138e6 --- /dev/null +++ b/internal/repository/policy.go @@ -0,0 +1,40 @@ +package repository + +import ( + "gitxyz/internal/models" + + "gorm.io/gorm" +) + +type PolicyRepository interface { + Add(p *models.Policy) error + Remove(id string) error + // FindApplicable returns policies matching the subject/action/resource triple. + FindApplicable(subjectType, subjectID, action, resourceType, resourceID string) ([]models.Policy, error) +} + +type PolicyRepositoryImpl struct { + db *gorm.DB +} + +func NewPolicyRepository(db *gorm.DB) *PolicyRepositoryImpl { + return &PolicyRepositoryImpl{db: db} +} + +func (p *PolicyRepositoryImpl) Add(pol *models.Policy) error { + return p.db.Create(pol).Error +} + +func (p *PolicyRepositoryImpl) Remove(id string) error { + return p.db.Where("id = ?", id).Delete(&models.Policy{}).Error +} + +func (p *PolicyRepositoryImpl) FindApplicable(subjectType, subjectID, action, resourceType, resourceID string) ([]models.Policy, error) { + var list []models.Policy + err := p.db. + Where("subject_type = ? AND subject_id = ?", subjectType, subjectID). + Where("action = ? AND resource_type = ?", action, resourceType). + Where("resource_id = ? OR resource_id = '*'", resourceID). + Find(&list).Error + return list, err +} diff --git a/internal/repository/repo_member.go b/internal/repository/repo_member.go new file mode 100644 index 0000000..ef3a99b --- /dev/null +++ b/internal/repository/repo_member.go @@ -0,0 +1,50 @@ +package repository + +import ( + "gitxyz/internal/models" + + "gorm.io/gorm" +) + +type RepoMemberRepository interface { + Add(m *models.RepositoryMember) error + FindByUserAndRepo(userID, repoID string) (models.RepositoryMember, error) + FindByRepo(repoID string) ([]models.RepositoryMember, error) + UpdateRole(userID, repoID, role string) error + Remove(userID, repoID string) error +} + +type RepoMemberRepositoryImpl struct { + db *gorm.DB +} + +func NewRepoMemberRepository(db *gorm.DB) *RepoMemberRepositoryImpl { + return &RepoMemberRepositoryImpl{db: db} +} + +func (r *RepoMemberRepositoryImpl) Add(m *models.RepositoryMember) error { + return r.db.Create(m).Error +} + +func (r *RepoMemberRepositoryImpl) FindByUserAndRepo(userID, repoID string) (models.RepositoryMember, error) { + var m models.RepositoryMember + err := r.db.Where("user_id = ? AND repo_id = ?", userID, repoID).First(&m).Error + return m, err +} + +func (r *RepoMemberRepositoryImpl) FindByRepo(repoID string) ([]models.RepositoryMember, error) { + var list []models.RepositoryMember + err := r.db.Where("repo_id = ?", repoID).Find(&list).Error + return list, err +} + +func (r *RepoMemberRepositoryImpl) UpdateRole(userID, repoID, role string) error { + return r.db.Model(&models.RepositoryMember{}). + Where("user_id = ? AND repo_id = ?", userID, repoID). + Update("role", role).Error +} + +func (r *RepoMemberRepositoryImpl) Remove(userID, repoID string) error { + return r.db.Where("user_id = ? AND repo_id = ?", userID, repoID). + Delete(&models.RepositoryMember{}).Error +} diff --git a/internal/repository/repo_member_test.go b/internal/repository/repo_member_test.go new file mode 100644 index 0000000..c585c8e --- /dev/null +++ b/internal/repository/repo_member_test.go @@ -0,0 +1,93 @@ +package repository + +import ( + "testing" + + "gitxyz/internal/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &models.User{}, &models.Repository{}, + &models.RepositoryMember{}, &models.Policy{}, + ); err != nil { + t.Fatalf("automigrate: %v", err) + } + return db +} + +func TestRepoMemberAddAndFind(t *testing.T) { + db := newTestDB(t) + repo := NewRepoMemberRepository(db) + + m := models.RepositoryMember{UserID: "u1", RepoID: "r1", Role: "maintainer"} + if err := repo.Add(&m); err != nil { + t.Fatalf("add: %v", err) + } + got, err := repo.FindByUserAndRepo("u1", "r1") + if err != nil { + t.Fatalf("find: %v", err) + } + if got.Role != "maintainer" { + t.Errorf("want maintainer got %q", got.Role) + } +} + +func TestPolicyRepositoryAddFindRemove(t *testing.T) { + db := newTestDB(t) + repo := NewPolicyRepository(db) + + p := models.Policy{ + SubjectType: "user", SubjectID: "u1", Action: "repo:read", + ResourceType: "repository", ResourceID: "r1", Effect: "allow", + } + if err := repo.Add(&p); err != nil { + t.Fatalf("add: %v", err) + } + if p.ID.String() == "" { + t.Fatal("expected generated ID") + } + + got, err := repo.FindApplicable("user", "u1", "repo:read", "repository", "r1") + if err != nil { + t.Fatalf("find: %v", err) + } + if len(got) != 1 || got[0].Effect != "allow" { + t.Fatalf("want 1 allow policy, got %+v", got) + } + + // Wildcard resource match: a policy with resource_id='*' applies to any repo. + if err := repo.Add(&models.Policy{ + SubjectType: "user", SubjectID: "u1", Action: "repo:read", + ResourceType: "repository", ResourceID: "*", Effect: "allow", + }); err != nil { + t.Fatalf("add wildcard: %v", err) + } + wild, err := repo.FindApplicable("user", "u1", "repo:read", "repository", "any-repo") + if err != nil { + t.Fatalf("find wildcard: %v", err) + } + if len(wild) != 1 { + t.Fatalf("want 1 wildcard match, got %d", len(wild)) + } + + if err := repo.Remove(p.ID.String()); err != nil { + t.Fatalf("remove: %v", err) + } + after, err := repo.FindApplicable("user", "u1", "repo:read", "repository", "r1") + if err != nil { + t.Fatalf("find after remove: %v", err) + } + // The specific policy is gone; the wildcard ('*') policy remains. + if len(after) != 1 || after[0].ResourceID != "*" { + t.Fatalf("want only wildcard remaining, got %+v", after) + } +} From 55422c52dd43235ee12e6046bf64d37eacbf06d5 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:18:11 +0700 Subject: [PATCH 21/49] feat(rbac): refactor AuthMiddleware to enhance authentication logic and structure --- modules/githttp/middlewares/auth.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/modules/githttp/middlewares/auth.go b/modules/githttp/middlewares/auth.go index 5a180ae..67b08c9 100644 --- a/modules/githttp/middlewares/auth.go +++ b/modules/githttp/middlewares/auth.go @@ -19,8 +19,14 @@ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { username, secret, ok := ctx.Request.BasicAuth() if !ok { ctx.Set("username", "") - } else if validateCredentials(userRepo, patRepo, username, secret) { + ctx.Set("role", "") + ctx.Set("token_scopes", "") + ctx.Set("token_type", "") + } else if res, authed := authenticate(userRepo, patRepo, username, secret); authed { ctx.Set("username", username) + ctx.Set("role", res.Role) + ctx.Set("token_scopes", res.Scopes) + ctx.Set("token_type", res.TokenType) } else { ctx.Header("WWW-Authenticate", `Basic realm="Git"`) ctx.AbortWithStatus(http.StatusUnauthorized) @@ -43,18 +49,23 @@ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { } } -func validateCredentials( +// authResult carries the resolved identity metadata. +type authResult struct { + Role string + Scopes string + TokenType string +} + +func authenticate( userRepo *repository.UserRepositoryImpl, patRepo *repository.PATRepositoryImpl, username, secret string, -) bool { +) (authResult, bool) { if user, err := userRepo.Authenticate(username, secret); err == nil && user.ID != [16]byte{} { - return true + return authResult{Role: user.Role, TokenType: "jwt"}, true } - - if _, err := patRepo.FindByTokenHash(helper.HashToken(secret)); err == nil { - return true + if pat, err := patRepo.FindByTokenHash(helper.HashToken(secret)); err == nil { + return authResult{Role: "", Scopes: pat.Scopes, TokenType: "pat"}, true } - - return false + return authResult{}, false } From 5a1811749c61d62993b9f82587c8da5e2d6f1cc6 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Thu, 16 Jul 2026 13:18:21 +0700 Subject: [PATCH 22/49] feat(rbac): refactor permission checks to use context and add integration tests for access control --- modules/githttp/services/authorize.go | 7 +- modules/githttp/services/permission.go | 163 +++++++++++++++--- .../services/permission_integration_test.go | 73 ++++++++ modules/githttp/services/permission_test.go | 160 +++++++++++++++++ 4 files changed, 373 insertions(+), 30 deletions(-) create mode 100644 modules/githttp/services/permission_integration_test.go create mode 100644 modules/githttp/services/permission_test.go diff --git a/modules/githttp/services/authorize.go b/modules/githttp/services/authorize.go index c008775..ae6cf27 100644 --- a/modules/githttp/services/authorize.go +++ b/modules/githttp/services/authorize.go @@ -30,19 +30,16 @@ func (s *GitServiceImpl) Authorize(ctx *gin.Context, options helper.Options) boo permission := NewPermission(s.db) - // Use the authenticated username (set by AuthMiddleware), not the URL owner. - username := ctx.GetString("username") - switch options.ServiceType { case helper.ServiceTypeReceivePack: // Push requires write permission. - if !permission.CanWrite(username, options.RepoName) { + if !permission.CanWrite(ctx, options.RepoName) { ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "write access denied"}) return false } case helper.ServiceTypeUploadPack: // Clone/fetch requires read permission. - if !permission.CanRead(username, options.RepoName) { + if !permission.CanRead(ctx, options.RepoName) { ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "read access denied"}) return false } diff --git a/modules/githttp/services/permission.go b/modules/githttp/services/permission.go index 448d551..cccaf4c 100644 --- a/modules/githttp/services/permission.go +++ b/modules/githttp/services/permission.go @@ -1,14 +1,17 @@ package services import ( + "gitxyz/internal/api/services" + "gitxyz/internal/models" "gitxyz/internal/repository" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) type Permission interface { - CanRead(username string, reponame string) bool - CanWrite(username string, reponame string) bool + CanRead(ctx *gin.Context, reponame string) bool + CanWrite(ctx *gin.Context, reponame string) bool } type PermissionImpl struct { @@ -16,52 +19,162 @@ type PermissionImpl struct { } func NewPermission(db *gorm.DB) Permission { - return &PermissionImpl{ - db: db, - } + return &PermissionImpl{db: db} } -// CanRead reports whether username may fetch/clone reponame. -// Public repositories are readable by anyone, including anonymous users -// (username == ""). Private repositories require the owner. -func (a *PermissionImpl) CanRead(username string, reponame string) bool { - repoRepo := repository.NewRepoRepository(a.db) - repo, err := repoRepo.FindByName(reponame) +// CanRead reports whether the request context may fetch/clone reponame. +func (a *PermissionImpl) CanRead(ctx *gin.Context, reponame string) bool { + repo, err := repository.NewRepoRepository(a.db).FindByName(reponame) if err != nil { return false } + // Public repos: readable by anyone (incl. anonymous). if !repo.IsPrivate { return true } - // Private: only the owner may read. - return username != "" && repo.UserID != "" && ownerMatches(a.db, username, repo.UserID) + // PAT scope check. + if ctx.GetString("token_type") == "pat" { + scopes := models.ParseScopes(ctx.GetString("token_scopes")) + if hasScope(scopes, models.ScopeRepoRead) { + return true + } + return false + } + + // System admin/owner bypass. + if isSystemAdmin(ctx) { + return true + } + + // Explicit ABAC policy check (deny wins). PAT subject uses token scopes + // already validated above; here we evaluate user/role policies. + if denied, err := a.policyDenies(ctx, "repo:read", repo.ID.String()); err == nil && denied { + return false + } + if ok, err := services.EvaluatePolicy(a.db, "user", ctx.GetString("user_id"), "repo:read", "repository", repo.ID.String()); err == nil && ok { + return true + } + + // Private repo: must be a member with at least reader role, or the creator. + userID, ok := a.resolveUserID(ctx.GetString("username")) + if !ok { + return false + } + if ctx.GetString("username") == repo.UserID { + return true + } + return a.memberCan(repo.ID.String(), userID, readRoles) } -// CanWrite reports whether username may push to reponame. -// Per project rules, public repositories accept pushes from anyone -// (including anonymous users). Private repositories require the owner. -func (a *PermissionImpl) CanWrite(username string, reponame string) bool { - repoRepo := repository.NewRepoRepository(a.db) - repo, err := repoRepo.FindByName(reponame) +// CanWrite reports whether the request context may push to reponame. +func (a *PermissionImpl) CanWrite(ctx *gin.Context, reponame string) bool { + repo, err := repository.NewRepoRepository(a.db).FindByName(reponame) if err != nil { return false } + // Public repos: writable by any logged-in user. if !repo.IsPrivate { + return ctx.GetString("username") != "" + } + + // PAT scope check. + if ctx.GetString("token_type") == "pat" { + scopes := models.ParseScopes(ctx.GetString("token_scopes")) + if hasScope(scopes, models.ScopeRepoWrite) { + return true + } + return false + } + + // System admin/owner bypass. + if isSystemAdmin(ctx) { + return true + } + + // Explicit ABAC policy check (deny wins). + if denied, err := a.policyDenies(ctx, "repo:write", repo.ID.String()); err == nil && denied { + return false + } + if ok, err := services.EvaluatePolicy(a.db, "user", ctx.GetString("user_id"), "repo:write", "repository", repo.ID.String()); err == nil && ok { return true } - return username != "" && repo.UserID != "" && ownerMatches(a.db, username, repo.UserID) + // Private repo: member with write-capable role, or the creator. + userID, ok := a.resolveUserID(ctx.GetString("username")) + if !ok { + return false + } + if ctx.GetString("username") == repo.UserID { + return true + } + return a.memberCan(repo.ID.String(), userID, writeRoles) } -// ownerMatches resolves username to its user ID and compares with repo.UserID. -func ownerMatches(db *gorm.DB, username, repoUserID string) bool { - userRepo := repository.NewUserRepository(db) - user, err := userRepo.FindByUsername(username) +var ( + readRoles = []string{"owner", "maintainer", "triager", "reader", "guest"} + writeRoles = []string{"owner", "maintainer", "triager"} +) + +func (a *PermissionImpl) resolveUserID(username string) (string, bool) { + if username == "" { + return "", false + } + user, err := repository.NewUserRepository(a.db).FindByUsername(username) + if err != nil { + return "", false + } + return user.ID.String(), true +} + +// policyDenies reports whether an explicit deny policy blocks the action for +// the current user. A deny always wins over membership. +func (a *PermissionImpl) policyDenies(ctx *gin.Context, action, repoID string) (bool, error) { + userID := ctx.GetString("user_id") + if userID == "" { + return false, nil + } + polRepo := repository.NewPolicyRepository(a.db) + ps, err := polRepo.FindApplicable("user", userID, action, "repository", repoID) + if err != nil { + return false, err + } + for _, p := range ps { + if p.Effect == "deny" { + return true, nil + } + } + return false, nil +} + +func (a *PermissionImpl) memberCan(repoID, userID string, allowed []string) bool { + if userID == "" { + return false + } + mem, err := repository.NewRepoMemberRepository(a.db).FindByUserAndRepo(userID, repoID) if err != nil { return false } - return user.ID.String() == repoUserID + for _, r := range allowed { + if mem.Role == r { + return true + } + } + return false +} + +func isSystemAdmin(ctx *gin.Context) bool { + role := ctx.GetString("role") + return role == models.RoleAdmin || role == models.RoleOwner +} + +func hasScope(scopes []string, want string) bool { + for _, s := range scopes { + if s == want || s == models.ScopeAdmin { + return true + } + } + return false } diff --git a/modules/githttp/services/permission_integration_test.go b/modules/githttp/services/permission_integration_test.go new file mode 100644 index 0000000..9d88621 --- /dev/null +++ b/modules/githttp/services/permission_integration_test.go @@ -0,0 +1,73 @@ +package services + +import ( + "testing" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestPermissionMatrix(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + db.AutoMigrate(&models.User{}, &models.Repository{}, &models.RepositoryMember{}) + ur := repository.NewUserRepository(db) + _ = ur.Create(&models.User{Username: "m1", Email: "m1@example.com", Role: "user"}) + mu, _ := ur.FindByUsername("m1") + rr := repository.NewRepoRepository(db) + rr.Create(&models.Repository{Name: "pubrepo", IsPrivate: false, UserID: "o1"}) + rr.Create(&models.Repository{Name: "privrepo", IsPrivate: true, UserID: "o1"}) + privRepo, _ := rr.FindByName("privrepo") + mr := repository.NewRepoMemberRepository(db) + if err := mr.Add(&models.RepositoryMember{UserID: mu.ID.String(), RepoID: privRepo.ID.String(), Role: "maintainer"}); err != nil { + t.Fatalf("add member: %v", err) + } + + perm := NewPermission(db) + + cases := []struct { + name string + ctx func() *gin.Context + repo string + wantR bool + wantW bool + }{ + {"anon public", ctxAnon, "pubrepo", true, false}, + {"login public", ctxLogin("user"), "pubrepo", true, true}, + {"anon private", ctxAnon, "privrepo", false, false}, + {"member maintainer private", ctxLogin("m1"), "privrepo", true, true}, + {"stranger private", ctxLogin("stranger"), "privrepo", false, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ctx := c.ctx() + if got := perm.CanRead(ctx, c.repo); got != c.wantR { + t.Errorf("CanRead: want %v got %v", c.wantR, got) + } + if got := perm.CanWrite(ctx, c.repo); got != c.wantW { + t.Errorf("CanWrite: want %v got %v", c.wantW, got) + } + }) + } +} + +func ctxAnon() *gin.Context { + return withCtx("", "", "jwt", "") +} + +func ctxLogin(user string) func() *gin.Context { + return func() *gin.Context { return withCtx(user, "user", "jwt", "") } +} + +func withCtx(user, role, tokType, scopes string) *gin.Context { + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", user) + ctx.Set("role", role) + ctx.Set("token_type", tokType) + ctx.Set("token_scopes", scopes) + return ctx +} diff --git a/modules/githttp/services/permission_test.go b/modules/githttp/services/permission_test.go new file mode 100644 index 0000000..ffea0fe --- /dev/null +++ b/modules/githttp/services/permission_test.go @@ -0,0 +1,160 @@ +package services + +import ( + "testing" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupPermDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + db.AutoMigrate(&models.User{}, &models.Repository{}, &models.RepositoryMember{}, &models.Policy{}) + return db +} + +func TestCanWritePublicLoggedIn(t *testing.T) { + db := setupPermDB(t) + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "pub", IsPrivate: false, UserID: "owner1"}) + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "anyone") + ctx.Set("role", "user") + ctx.Set("token_type", "jwt") + ctx.Set("token_scopes", "") + + if !perm.CanWrite(ctx, "pub") { + t.Error("logged-in user should be able to write public repo") + } +} + +func TestCanWritePrivateNonMember(t *testing.T) { + db := setupPermDB(t) + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "stranger") + ctx.Set("role", "user") + ctx.Set("token_type", "jwt") + ctx.Set("token_scopes", "") + + if perm.CanWrite(ctx, "priv") { + t.Error("non-member should NOT write private repo") + } +} + +func TestCanReadPrivateMemberReader(t *testing.T) { + db := setupPermDB(t) + userRepo := repository.NewUserRepository(db) + _ = userRepo.Create(&models.User{Username: "reader1", Email: "reader1@example.com", Role: "user"}) + u, _ := userRepo.FindByUsername("reader1") + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + privRepo, _ := repoRepo.FindByName("priv") + memRepo := repository.NewRepoMemberRepository(db) + if err := memRepo.Add(&models.RepositoryMember{UserID: u.ID.String(), RepoID: privRepo.ID.String(), Role: "reader"}); err != nil { + t.Fatalf("add member: %v", err) + } + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "reader1") + ctx.Set("role", "user") + ctx.Set("token_type", "jwt") + ctx.Set("token_scopes", "") + + if !perm.CanRead(ctx, "priv") { + t.Error("reader member should read private repo") + } + if perm.CanWrite(ctx, "priv") { + t.Error("reader member should NOT write private repo") + } +} + +func TestPATScopeDeny(t *testing.T) { + db := setupPermDB(t) + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "patuser") + ctx.Set("role", "") + ctx.Set("token_type", "pat") + ctx.Set("token_scopes", "repo:read") + + if perm.CanWrite(ctx, "priv") { + t.Error("PAT with repo:read only must NOT write") + } + if !perm.CanRead(ctx, "priv") { + t.Error("PAT with repo:read should read") + } +} + +func TestABACPolicyDeniesMember(t *testing.T) { + db := setupPermDB(t) + userRepo := repository.NewUserRepository(db) + _ = userRepo.Create(&models.User{Username: "reader1", Email: "reader1@example.com", Role: "user"}) + u, _ := userRepo.FindByUsername("reader1") + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + privRepo, _ := repoRepo.FindByName("priv") + memRepo := repository.NewRepoMemberRepository(db) + if err := memRepo.Add(&models.RepositoryMember{UserID: u.ID.String(), RepoID: privRepo.ID.String(), Role: "reader"}); err != nil { + t.Fatalf("add member: %v", err) + } + // Explicit deny policy overrides membership. + polRepo := repository.NewPolicyRepository(db) + if err := polRepo.Add(&models.Policy{ + SubjectType: "user", SubjectID: u.ID.String(), Action: "repo:read", + ResourceType: "repository", ResourceID: privRepo.ID.String(), Effect: "deny", + }); err != nil { + t.Fatalf("add policy: %v", err) + } + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "reader1") + ctx.Set("user_id", u.ID.String()) + ctx.Set("role", "user") + ctx.Set("token_type", "jwt") + ctx.Set("token_scopes", "") + + if perm.CanRead(ctx, "priv") { + t.Error("explicit deny policy must override reader membership") + } +} + +func TestCreatorCanAccessPrivateRepo(t *testing.T) { + db := setupPermDB(t) + userRepo := repository.NewUserRepository(db) + _ = userRepo.Create(&models.User{Username: "owner1", Email: "owner1@example.com", Role: "user"}) + repoRepo := repository.NewRepoRepository(db) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + + perm := NewPermission(db) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("username", "owner1") + ctx.Set("role", "user") + ctx.Set("token_type", "jwt") + ctx.Set("token_scopes", "") + + if !perm.CanRead(ctx, "priv") { + t.Error("repo creator should read own private repo") + } + if !perm.CanWrite(ctx, "priv") { + t.Error("repo creator should write own private repo") + } +} From 4b6868a4f765b745e238d777336ad28429275671 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:10:50 +0700 Subject: [PATCH 23/49] set default log level to info --- main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main.go b/main.go index 355b612..5de6832 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,9 @@ func init() { if !viper.IsSet("jwt_expiry_hours") { viper.Set("jwt_expiry_hours", 24) } + if !viper.IsSet("log_level") { + viper.Set("log_level", "info") + } viper.SafeWriteConfig() From de10596c50a8f138fbc75140b6a24eb8df680864 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:11:12 +0700 Subject: [PATCH 24/49] feat(rbac): implement RequestID middleware for trace logging and add logger package with context support --- internal/api/middlewares/requestid.go | 66 ++++++++++++++++++++ internal/bootstrap/init.go | 14 ++++- internal/logger/logger.go | 87 +++++++++++++++++++++++++++ internal/logger/logger_test.go | 37 ++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 internal/api/middlewares/requestid.go create mode 100644 internal/logger/logger.go create mode 100644 internal/logger/logger_test.go diff --git a/internal/api/middlewares/requestid.go b/internal/api/middlewares/requestid.go new file mode 100644 index 0000000..89d053b --- /dev/null +++ b/internal/api/middlewares/requestid.go @@ -0,0 +1,66 @@ +package middlewares + +import ( + "log/slog" + "time" + + "gitxyz/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// TraceHeader is the response/request header carrying the trace ID. +const TraceHeader = "X-Trace-Id" + +// RequestID generates a trace ID per request, stores it in the context and +// response header, and emits a structured JSON access log on completion. +func RequestID() gin.HandlerFunc { + return func(ctx *gin.Context) { + // Reuse an incoming trace id when provided (e.g. via gateway/proxy). + traceID := ctx.GetHeader(TraceHeader) + if traceID == "" { + traceID = uuid.NewString() + } + + ctx.Set("trace_id", traceID) + ctx.Header(TraceHeader, traceID) + ctx.Request = ctx.Request.WithContext(logger.WithTrace(ctx.Request.Context(), traceID)) + + start := time.Now() + path := ctx.Request.URL.Path + raw := ctx.Request.URL.RawQuery + + ctx.Next() + + latency := time.Since(start) + status := ctx.Writer.Status() + clientIP := ctx.ClientIP() + method := ctx.Request.Method + if raw != "" { + path = path + "?" + raw + } + + log := logger.FromContext(ctx.Request.Context()) + attrs := []any{ + slog.String("method", method), + slog.String("path", path), + slog.Int("status", status), + slog.Duration("latency", latency), + slog.String("client_ip", clientIP), + slog.String("user_agent", ctx.Request.UserAgent()), + } + if len(ctx.Errors) > 0 { + attrs = append(attrs, slog.String("error", ctx.Errors.ByType(gin.ErrorTypePrivate).String())) + } + + switch { + case status >= 500: + log.Error("request completed", attrs...) + case status >= 400: + log.Warn("request completed", attrs...) + default: + log.Info("request completed", attrs...) + } + } +} diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 82330c0..5d7e590 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -1,14 +1,24 @@ package bootstrap -import "github.com/gin-gonic/gin" +import ( + "gitxyz/internal/api/middlewares" + "gitxyz/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/spf13/viper" +) func Initialize() { gin.SetMode(gin.DebugMode) + // Configure structured JSON logging from config (log_level). + logger.Configure(viper.GetString("log_level")) + db := NewDatabase() server := gin.New() - server.Use(gin.Logger()) + server.Use(gin.Recovery()) + server.Use(middlewares.RequestID()) MakeRouter(server, db) diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..bf700a9 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,87 @@ +package logger + +import ( + "context" + "log/slog" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +// contextKey is a private type to avoid context key collisions. +type contextKey string + +// TraceIDKey is the context key under which the request/trace ID is stored. +const TraceIDKey contextKey = "trace_id" + +// Logger is the application-wide structured logger. +var Logger *slog.Logger + +func init() { + // Default to info level; overridden by Configure. + Logger = newJSONLogger(slog.LevelInfo) +} + +// Configure (re)initializes the global logger with the given level string +// (e.g. "debug", "info", "warn", "error"). Invalid values default to info. +func Configure(level string) { + Logger = newJSONLogger(parseLevel(level)) +} + +func newJSONLogger(level slog.Level) *slog.Logger { + opts := &slog.HandlerOptions{ + Level: level, + AddSource: false, + } + handler := slog.NewJSONHandler(os.Stdout, opts) + return slog.New(handler) +} + +func parseLevel(level string) slog.Level { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// WithTrace returns a context carrying the given trace ID. +func WithTrace(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, TraceIDKey, traceID) +} + +// TraceFrom extracts the trace ID from the context, or "" if absent. +func TraceFrom(ctx context.Context) string { + if v, ok := ctx.Value(TraceIDKey).(string); ok { + return v + } + return "" +} + +// FromContext returns a logger that includes the trace ID from ctx as a field. +// Falls back to the global logger when no trace ID is present. +func FromContext(ctx context.Context) *slog.Logger { + traceID := TraceFrom(ctx) + if traceID == "" { + return Logger + } + return Logger.With(slog.String("trace_id", traceID)) +} + +// FromGin returns a logger bound to a Gin request context. It safely handles +// test contexts where Request may be nil by falling back to Background. +func FromGin(ctx *gin.Context) *slog.Logger { + if ctx == nil { + return Logger + } + if ctx.Request == nil { + return FromContext(context.Background()) + } + return FromContext(ctx.Request.Context()) +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..b103886 --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,37 @@ +package logger + +import ( + "context" + "testing" +) + +func TestTraceRoundTrip(t *testing.T) { + ctx := WithTrace(context.Background(), "trace-123") + if got := TraceFrom(ctx); got != "trace-123" { + t.Fatalf("want trace-123 got %q", got) + } +} + +func TestTraceFromMissing(t *testing.T) { + if got := TraceFrom(context.Background()); got != "" { + t.Fatalf("want empty got %q", got) + } +} + +func TestFromContextFallsBackToGlobal(t *testing.T) { + l := FromContext(context.Background()) + if l == nil { + t.Fatal("expected non-nil logger") + } +} + +func TestConfigureLevel(t *testing.T) { + Configure("debug") + if Logger == nil { + t.Fatal("expected logger after configure") + } + Configure("invalid") + if Logger == nil { + t.Fatal("expected logger after invalid level") + } +} From 4a99915a6a378383465e645e568715e90b8e3119 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:11:20 +0700 Subject: [PATCH 25/49] feat(rbac): enhance logging in AuthMiddleware and authorizeRepo for better traceability --- modules/githttp/middlewares/auth.go | 18 +++++++++-- modules/githttp/routes.go | 7 +++-- modules/githttp/services/authorize.go | 45 ++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/modules/githttp/middlewares/auth.go b/modules/githttp/middlewares/auth.go index 67b08c9..1230e6a 100644 --- a/modules/githttp/middlewares/auth.go +++ b/modules/githttp/middlewares/auth.go @@ -1,10 +1,13 @@ package middlewares import ( + "log/slog" + "net/http" + "gitxyz/internal/helper" + "gitxyz/internal/logger" "gitxyz/internal/repository" githttpHelper "gitxyz/modules/githttp/helper" - "net/http" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -16,26 +19,37 @@ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { repoRepo := repository.NewRepoRepository(db) return func(ctx *gin.Context) { + log := logger.FromGin(ctx) + options := githttpHelper.MakeOptionsFromContext(ctx, db) username, secret, ok := ctx.Request.BasicAuth() if !ok { + log.Debug("git auth: anonymous request", + slog.String("repo", options.RepoName)) ctx.Set("username", "") ctx.Set("role", "") ctx.Set("token_scopes", "") ctx.Set("token_type", "") } else if res, authed := authenticate(userRepo, patRepo, username, secret); authed { + log.Info("git auth: authenticated", + slog.String("username", username), + slog.String("token_type", res.TokenType), + slog.String("role", res.Role)) ctx.Set("username", username) ctx.Set("role", res.Role) ctx.Set("token_scopes", res.Scopes) ctx.Set("token_type", res.TokenType) } else { + log.Warn("git auth: failed", + slog.String("username", username)) ctx.Header("WWW-Authenticate", `Basic realm="Git"`) ctx.AbortWithStatus(http.StatusUnauthorized) return } - options := githttpHelper.MakeOptionsFromContext(ctx, db) repo, err := repoRepo.FindByName(options.RepoName) if err != nil { + log.Warn("git auth: repository not found", + slog.String("repo", options.RepoName)) ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{ "error": "repository not found", }) diff --git a/modules/githttp/routes.go b/modules/githttp/routes.go index e875f11..a7e4d85 100644 --- a/modules/githttp/routes.go +++ b/modules/githttp/routes.go @@ -1,7 +1,8 @@ package githttp import ( - "gitxyz/modules/githttp/middlewares" + apimw "gitxyz/internal/api/middlewares" + githttpmw "gitxyz/modules/githttp/middlewares" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -11,8 +12,8 @@ func RegisterRoutes(server *gin.Engine, tx *gorm.DB) { controller := NewController(tx) routes := server.Group("/:username/:reponame") - - routes.Use(middlewares.AuthMiddleware(tx)) + routes.Use(apimw.RequestID()) + routes.Use(githttpmw.AuthMiddleware(tx)) routes.Match([]string{"GET"}, "/info/refs", controller.InfoRefs) routes.Match([]string{"POST", "OPTIONS"}, "/git-receive-pack", controller.ReceivePack) diff --git a/modules/githttp/services/authorize.go b/modules/githttp/services/authorize.go index ae6cf27..a2b81f6 100644 --- a/modules/githttp/services/authorize.go +++ b/modules/githttp/services/authorize.go @@ -2,22 +2,28 @@ package services import ( "errors" + "log/slog" + "net/http" + + "gitxyz/internal/logger" "gitxyz/internal/repository" "gitxyz/modules/githttp/helper" - "net/http" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func authorizeRepo(ctx *gin.Context, db *gorm.DB, reponame string) { + log := logger.FromGin(ctx) repoRepo := repository.NewRepoRepository(db) repo, err := repoRepo.FindByName(reponame) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + log.Warn("git authorize: repository not found", slog.String("repo", reponame)) ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) return } + log.Error("git authorize: database error", slog.String("repo", reponame), slog.String("error", err.Error())) ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "database error"}) return } @@ -26,24 +32,61 @@ func authorizeRepo(ctx *gin.Context, db *gorm.DB, reponame string) { } func (s *GitServiceImpl) Authorize(ctx *gin.Context, options helper.Options) bool { + log := logger.FromGin(ctx) authorizeRepo(ctx, s.db, options.RepoName) + if ctx.IsAborted() { + return false + } permission := NewPermission(s.db) + username := ctx.GetString("username") + anonymous := username == "" switch options.ServiceType { case helper.ServiceTypeReceivePack: // Push requires write permission. if !permission.CanWrite(ctx, options.RepoName) { + // Anonymous clients must receive a Basic auth challenge so Git + // prompts for credentials instead of failing with 403. + if anonymous { + log.Info("git authorize: write requires authentication", + slog.String("repo", options.RepoName)) + ctx.Header("WWW-Authenticate", `Basic realm="Git"`) + ctx.AbortWithStatus(http.StatusUnauthorized) + return false + } + log.Warn("git authorize: write denied", + slog.String("repo", options.RepoName), + slog.String("username", username)) ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "write access denied"}) return false } + log.Info("git authorize: write granted", + slog.String("repo", options.RepoName), + slog.String("username", username)) case helper.ServiceTypeUploadPack: // Clone/fetch requires read permission. if !permission.CanRead(ctx, options.RepoName) { + // Anonymous clients must receive a Basic auth challenge so Git + // prompts for credentials instead of failing with 403. + if anonymous { + log.Info("git authorize: read requires authentication", + slog.String("repo", options.RepoName)) + ctx.Header("WWW-Authenticate", `Basic realm="Git"`) + ctx.AbortWithStatus(http.StatusUnauthorized) + return false + } + log.Warn("git authorize: read denied", + slog.String("repo", options.RepoName), + slog.String("username", username)) ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "read access denied"}) return false } + log.Info("git authorize: read granted", + slog.String("repo", options.RepoName), + slog.String("username", username)) default: + log.Warn("git authorize: invalid service type", slog.String("service", string(options.ServiceType))) ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid service type"}) return false } From cb139931d5161dcf9808836224bf1377036f7659 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:11:27 +0700 Subject: [PATCH 26/49] feat(rbac): enhance logging in info-refs, permission, and receive-pack services for better traceability --- modules/githttp/services/info-refs.go | 13 +++++++ modules/githttp/services/permission.go | 44 ++++++++++++++++++------ modules/githttp/services/receive-pack.go | 22 +++++++++--- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/modules/githttp/services/info-refs.go b/modules/githttp/services/info-refs.go index ab20c54..6f878dd 100644 --- a/modules/githttp/services/info-refs.go +++ b/modules/githttp/services/info-refs.go @@ -2,8 +2,10 @@ package services import ( "fmt" + "log/slog" "net/http" + "gitxyz/internal/logger" "gitxyz/modules/githttp/helper" "gitxyz/pkg/git" @@ -11,16 +13,19 @@ import ( ) func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { + log := logger.FromGin(ctx) options := helper.MakeOptionsFromContext(ctx, s.db) repoID := ctx.GetString("repo_id") if repoID == "" { + log.Warn("git info-refs: repository not found", slog.String("repo", options.RepoName)) ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) return } storagePath, err := options.EnsureRepositoryStorage(repoID) if err != nil { + log.Error("git info-refs: storage error", slog.String("repo", options.RepoName), slog.String("error", err.Error())) ctx.AbortWithError(500, err) return } @@ -33,23 +38,31 @@ func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { cmd := git.NewCommand() isBareRepository, err := cmd.IsBareRepository(storagePath) if err != nil { + log.Error("git info-refs: bare check failed", slog.String("repo", options.RepoName), slog.String("error", err.Error())) ctx.AbortWithError(500, err) return } if !isBareRepository { if _, err := cmd.InitBare(storagePath); err != nil { + log.Error("git info-refs: init bare failed", slog.String("repo", options.RepoName), slog.String("error", err.Error())) ctx.AbortWithError(500, err) return } + log.Info("git info-refs: initialized bare repository", slog.String("repo", options.RepoName)) } refs, err := cmd.ReceivePack(storagePath) if err != nil { + log.Error("git info-refs: receive-pack failed", slog.String("repo", options.RepoName), slog.String("error", err.Error())) ctx.AbortWithError(500, err) return } + log.Info("git info-refs: served advertisement", + slog.String("repo", options.RepoName), + slog.String("service", string(options.ServiceType))) + ctx.Header("Cache-Control", "no-cache") ctx.Header("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", options.ServiceType)) ctx.Writer.Write(helper.PacketWrite( diff --git a/modules/githttp/services/permission.go b/modules/githttp/services/permission.go index cccaf4c..81babde 100644 --- a/modules/githttp/services/permission.go +++ b/modules/githttp/services/permission.go @@ -1,7 +1,10 @@ package services import ( + "log/slog" + "gitxyz/internal/api/services" + "gitxyz/internal/logger" "gitxyz/internal/models" "gitxyz/internal/repository" @@ -24,93 +27,112 @@ func NewPermission(db *gorm.DB) Permission { // CanRead reports whether the request context may fetch/clone reponame. func (a *PermissionImpl) CanRead(ctx *gin.Context, reponame string) bool { + log := logger.FromGin(ctx) repo, err := repository.NewRepoRepository(a.db).FindByName(reponame) if err != nil { + log.Debug("permission: repo lookup failed", slog.String("repo", reponame), slog.String("error", err.Error())) return false } // Public repos: readable by anyone (incl. anonymous). if !repo.IsPrivate { + log.Debug("permission: public repo read allowed", slog.String("repo", reponame)) return true } // PAT scope check. if ctx.GetString("token_type") == "pat" { scopes := models.ParseScopes(ctx.GetString("token_scopes")) - if hasScope(scopes, models.ScopeRepoRead) { - return true - } - return false + allowed := hasScope(scopes, models.ScopeRepoRead) + log.Debug("permission: PAT read check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } // System admin/owner bypass. if isSystemAdmin(ctx) { + log.Debug("permission: admin/owner bypass read", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return true } // Explicit ABAC policy check (deny wins). PAT subject uses token scopes // already validated above; here we evaluate user/role policies. if denied, err := a.policyDenies(ctx, "repo:read", repo.ID.String()); err == nil && denied { + log.Debug("permission: ABAC deny read", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return false } if ok, err := services.EvaluatePolicy(a.db, "user", ctx.GetString("user_id"), "repo:read", "repository", repo.ID.String()); err == nil && ok { + log.Debug("permission: ABAC allow read", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return true } // Private repo: must be a member with at least reader role, or the creator. userID, ok := a.resolveUserID(ctx.GetString("username")) if !ok { + log.Debug("permission: read denied (no user)", slog.String("repo", reponame)) return false } if ctx.GetString("username") == repo.UserID { + log.Debug("permission: creator read allowed", slog.String("repo", reponame)) return true } - return a.memberCan(repo.ID.String(), userID, readRoles) + allowed := a.memberCan(repo.ID.String(), userID, readRoles) + log.Debug("permission: membership read check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } // CanWrite reports whether the request context may push to reponame. func (a *PermissionImpl) CanWrite(ctx *gin.Context, reponame string) bool { + log := logger.FromGin(ctx) repo, err := repository.NewRepoRepository(a.db).FindByName(reponame) if err != nil { + log.Debug("permission: repo lookup failed", slog.String("repo", reponame), slog.String("error", err.Error())) return false } // Public repos: writable by any logged-in user. if !repo.IsPrivate { - return ctx.GetString("username") != "" + allowed := ctx.GetString("username") != "" + log.Debug("permission: public repo write check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } // PAT scope check. if ctx.GetString("token_type") == "pat" { scopes := models.ParseScopes(ctx.GetString("token_scopes")) - if hasScope(scopes, models.ScopeRepoWrite) { - return true - } - return false + allowed := hasScope(scopes, models.ScopeRepoWrite) + log.Debug("permission: PAT write check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } // System admin/owner bypass. if isSystemAdmin(ctx) { + log.Debug("permission: admin/owner bypass write", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return true } // Explicit ABAC policy check (deny wins). if denied, err := a.policyDenies(ctx, "repo:write", repo.ID.String()); err == nil && denied { + log.Debug("permission: ABAC deny write", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return false } if ok, err := services.EvaluatePolicy(a.db, "user", ctx.GetString("user_id"), "repo:write", "repository", repo.ID.String()); err == nil && ok { + log.Debug("permission: ABAC allow write", slog.String("repo", reponame), slog.String("username", ctx.GetString("username"))) return true } // Private repo: member with write-capable role, or the creator. userID, ok := a.resolveUserID(ctx.GetString("username")) if !ok { + log.Debug("permission: write denied (no user)", slog.String("repo", reponame)) return false } if ctx.GetString("username") == repo.UserID { + log.Debug("permission: creator write allowed", slog.String("repo", reponame)) return true } - return a.memberCan(repo.ID.String(), userID, writeRoles) + allowed := a.memberCan(repo.ID.String(), userID, writeRoles) + log.Debug("permission: membership write check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } var ( diff --git a/modules/githttp/services/receive-pack.go b/modules/githttp/services/receive-pack.go index 2650812..c51fc10 100644 --- a/modules/githttp/services/receive-pack.go +++ b/modules/githttp/services/receive-pack.go @@ -1,31 +1,45 @@ package services import ( + "log/slog" + "net/http" + + "gitxyz/internal/logger" "gitxyz/modules/githttp/helper" "gitxyz/pkg/git" - "log" - "net/http" "github.com/gin-gonic/gin" ) func (s *GitServiceImpl) ReceivePack(ctx *gin.Context) { - log.Println(">>> ReceivePack handler") - + log := logger.FromGin(ctx) options := helper.MakeOptionsFromContext(ctx, s.db) repoID := ctx.GetString("repo_id") if repoID == "" { + log.Warn("git receive-pack: repository not found", slog.String("repo", options.RepoName)) ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) return } + log.Info("git receive-pack: push started", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username"))) + ctx.Header("Content-Type", "application/x-git-receive-pack-result") cmd := git.NewCommand() err := cmd.ReceivePackRPC(options.GetRepositoryStorage(repoID), ctx.Request.Body, ctx.Writer) if err != nil { + log.Error("git receive-pack: push failed", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username")), + slog.String("error", err.Error())) ctx.AbortWithError(http.StatusInternalServerError, err) return } + + log.Info("git receive-pack: push completed", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username"))) } From 99275920648ce08d47ba0beb3686977c9f0bfdf0 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:11:31 +0700 Subject: [PATCH 27/49] feat(rbac): add logging to UploadPack method for better traceability --- modules/githttp/services/upload-pack.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/githttp/services/upload-pack.go b/modules/githttp/services/upload-pack.go index b78f895..66cc17e 100644 --- a/modules/githttp/services/upload-pack.go +++ b/modules/githttp/services/upload-pack.go @@ -1,7 +1,13 @@ package services -import "github.com/gin-gonic/gin" +import ( + "gitxyz/internal/logger" -func (s *GitServiceImpl) UploadPack(ctx *gin.Context) { + "github.com/gin-gonic/gin" +) +func (s *GitServiceImpl) UploadPack(ctx *gin.Context) { + log := logger.FromGin(ctx) + log.Warn("git upload-pack: not implemented") + ctx.AbortWithStatusJSON(501, gin.H{"error": "upload-pack not implemented"}) } From fe48acbf09618831548885c54446d8838cc0900c Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 21:11:38 +0700 Subject: [PATCH 28/49] feat(rbac): add log_level configuration to gitxyz.yaml for improved logging control --- config/gitxyz.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/gitxyz.yaml b/config/gitxyz.yaml index 99451f5..8a8c7b2 100644 --- a/config/gitxyz.yaml +++ b/config/gitxyz.yaml @@ -1,3 +1,4 @@ database_url: host=localhost user=gitxyz password=gitxyz-kece-banget dbname=gitxyz port=5432 sslmode=disable TimeZone=Asia/Jakarta volume_path: ./volumes -executable: /usr/bin/git \ No newline at end of file +executable: /usr/bin/git +log_level: info \ No newline at end of file From d4a4567dc6a0753b85b1b975322d72d889851841 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:20:19 +0700 Subject: [PATCH 29/49] feat(rbac): update .gitignore to include docs/superpowers and gitxyz directories --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6c0d470..59f6903 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ tmp .env .agents -.claude \ No newline at end of file +.claude + +docs/superpowers +gitxyz \ No newline at end of file From 6268b334fc8ff4f9c686a598b0a129d49cb539fc Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:00 +0700 Subject: [PATCH 30/49] feat(rbac): implement repository management and policy handling in RepoController --- internal/api/controllers/repo.go | 209 +++++++++++++++++++++++++++++++ internal/api/dto/request/user.go | 13 ++ 2 files changed, 222 insertions(+) diff --git a/internal/api/controllers/repo.go b/internal/api/controllers/repo.go index 887f934..2ebd747 100644 --- a/internal/api/controllers/repo.go +++ b/internal/api/controllers/repo.go @@ -3,6 +3,7 @@ package controllers import ( "net/http" + dto "gitxyz/internal/api/dto/request" response "gitxyz/internal/api/dto/response" "gitxyz/internal/api/services" "gitxyz/internal/models" @@ -13,6 +14,18 @@ import ( type RepoController interface { Create(ctx *gin.Context) + Get(ctx *gin.Context) + Update(ctx *gin.Context) + Delete(ctx *gin.Context) + + ListCollaborators(ctx *gin.Context) + AddCollaborator(ctx *gin.Context) + UpdateCollaborator(ctx *gin.Context) + RemoveCollaborator(ctx *gin.Context) + + ListPolicies(ctx *gin.Context) + AddPolicy(ctx *gin.Context) + RemovePolicy(ctx *gin.Context) } type RepoControllerImpl struct { @@ -65,3 +78,199 @@ func (c *RepoControllerImpl) Create(ctx *gin.Context) { "data": response.ToRepositoryResponse(repo, owner.(string)), }) } + +func (c *RepoControllerImpl) Get(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + repo, err := c.service.GetRepository(owner, name) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "repository fetched", + "data": response.ToRepositoryResponse(repo, owner), + }) +} + +func (c *RepoControllerImpl) Update(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + var request struct { + Description string `json:"description"` + IsPrivate *bool `json:"is_private"` + IsActive *bool `json:"is_active"` + } + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := &models.Repository{ + Description: request.Description, + } + if request.IsPrivate != nil { + patch.IsPrivate = *request.IsPrivate + } + if request.IsActive != nil { + patch.IsActive = *request.IsActive + } + + repo, err := c.service.UpdateRepository(owner, name, patch) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "repository updated", + "data": response.ToRepositoryResponse(repo, owner), + }) +} + +func (c *RepoControllerImpl) Delete(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + if err := c.service.DeleteRepository(owner, name); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "repository deleted"}) +} + +func (c *RepoControllerImpl) ListCollaborators(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + members, err := c.service.ListCollaborators(owner, name) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "collaborators fetched", + "data": response.ToCollaboratorResponseSlice(members), + }) +} + +func (c *RepoControllerImpl) AddCollaborator(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + var request dto.CollaboratorRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + member, err := c.service.AddCollaborator(owner, name, request.Username, request.Role) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "message": "collaborator added", + "data": response.ToCollaboratorResponse(member), + }) +} + +func (c *RepoControllerImpl) UpdateCollaborator(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + username := ctx.Param("username") + + var request struct { + Role string `json:"role" binding:"required"` + } + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + member, err := c.service.UpdateCollaborator(owner, name, username, request.Role) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "collaborator updated", + "data": response.ToCollaboratorResponse(member), + }) +} + +func (c *RepoControllerImpl) RemoveCollaborator(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + username := ctx.Param("username") + + if err := c.service.RemoveCollaborator(owner, name, username); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "collaborator removed"}) +} + +func (c *RepoControllerImpl) ListPolicies(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + policies, err := c.service.ListPolicies(owner, name) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "policies fetched", + "data": response.ToPolicyResponseSlice(policies), + }) +} + +func (c *RepoControllerImpl) AddPolicy(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + var request dto.PolicyRequest + if err := ctx.ShouldBindBodyWithJSON(&request); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resourceID := request.ResourceID + if resourceID == "" { + resourceID = "*" + } + + pol, err := c.service.AddPolicy(owner, name, request.SubjectType, request.SubjectID, request.Action, resourceID, request.Effect) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "message": "policy added", + "data": response.ToPolicyResponse(pol), + }) +} + +func (c *RepoControllerImpl) RemovePolicy(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + policyID := ctx.Param("id") + + if err := c.service.RemovePolicy(owner, name, policyID); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "policy removed"}) +} diff --git a/internal/api/dto/request/user.go b/internal/api/dto/request/user.go index 07747ad..7b0c0ab 100644 --- a/internal/api/dto/request/user.go +++ b/internal/api/dto/request/user.go @@ -37,3 +37,16 @@ type CreateTokenRequest struct { Name string `json:"name" binding:"required"` Scopes string `json:"scopes"` } + +type CollaboratorRequest struct { + Username string `json:"username" binding:"required"` + Role string `json:"role" binding:"required"` +} + +type PolicyRequest struct { + SubjectType string `json:"subject_type" binding:"required"` + SubjectID string `json:"subject_id" binding:"required"` + Action string `json:"action" binding:"required"` + ResourceID string `json:"resource_id"` + Effect string `json:"effect" binding:"required"` +} From 4402121f25c6b85f30b236ecb9b49871a9f0018b Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:11 +0700 Subject: [PATCH 31/49] feat(rbac): add collaborator and policy management routes in RepoController --- internal/api/dto/response/repo.go | 54 +++++++++++++++++++++++++++++++ internal/api/routes/repo.go | 29 ++++++++++++++--- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/internal/api/dto/response/repo.go b/internal/api/dto/response/repo.go index e5e9ba3..9aa438b 100644 --- a/internal/api/dto/response/repo.go +++ b/internal/api/dto/response/repo.go @@ -42,3 +42,57 @@ func ToRepositoryResponseSlice(repos []models.Repository, owner string) []Reposi } return out } + +type CollaboratorResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + RepoID string `json:"repo_id"` + Role string `json:"role"` +} + +func ToCollaboratorResponse(m *models.RepositoryMember) CollaboratorResponse { + return CollaboratorResponse{ + ID: m.ID.String(), + UserID: m.UserID, + RepoID: m.RepoID, + Role: m.Role, + } +} + +func ToCollaboratorResponseSlice(list []models.RepositoryMember) []CollaboratorResponse { + out := make([]CollaboratorResponse, 0, len(list)) + for i := range list { + out = append(out, ToCollaboratorResponse(&list[i])) + } + return out +} + +type PolicyResponse struct { + ID string `json:"id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + Effect string `json:"effect"` +} + +func ToPolicyResponse(p *models.Policy) PolicyResponse { + return PolicyResponse{ + ID: p.ID.String(), + SubjectType: p.SubjectType, + SubjectID: p.SubjectID, + Action: p.Action, + ResourceType: p.ResourceType, + ResourceID: p.ResourceID, + Effect: p.Effect, + } +} + +func ToPolicyResponseSlice(list []models.Policy) []PolicyResponse { + out := make([]PolicyResponse, 0, len(list)) + for i := range list { + out = append(out, ToPolicyResponse(&list[i])) + } + return out +} diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 9a6b76f..f49a796 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -3,14 +3,35 @@ package routes import ( "gitxyz/internal/api/controllers" "gitxyz/internal/api/middlewares" + "gitxyz/internal/models" ) func (r *RoutesImpl) RegisterRepositories() { controller := controllers.NewRepoController(r.db) - routes := r.engine.Group("/api/repos") - protectedRoutes := routes.Group("") - protectedRoutes.Use(middlewares.AuthRequired()) + // /api/repos — repository collection. + repos := r.engine.Group("/api/repos") + repos.Use(middlewares.AuthRequired()) + repos.POST("", controller.Create) - protectedRoutes.POST("", controller.Create) + // /api/repos/:owner/:reponame — single repository + collaborators + policies. + repo := r.engine.Group("/api/repos/:owner/:reponame") + repo.Use(middlewares.AuthRequired()) + + // Read requires repo:read scope (or admin/owner role via RequireScope). + repo.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.Get) + // Write requires repo:write scope (or admin/owner role via RequireScope). + repo.PATCH("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.Update) + repo.DELETE("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.Delete) + + // Collaborator management — repo:write scope. + repo.GET("/collaborators", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListCollaborators) + repo.POST("/collaborators", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.AddCollaborator) + repo.PUT("/collaborators/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.UpdateCollaborator) + repo.DELETE("/collaborators/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.RemoveCollaborator) + + // Policy (ABAC) management — repo:write scope. + repo.GET("/policies", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListPolicies) + repo.POST("/policies", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.AddPolicy) + repo.DELETE("/policies/:id", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.RemovePolicy) } From bec040009660d6289f0ac67be002be18b9c45e1c Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:16 +0700 Subject: [PATCH 32/49] feat(rbac): enhance RepoService with repository and collaborator management methods --- internal/api/services/repo.go | 187 +++++++++++++++++++++++++++++++++- internal/api/services/user.go | 4 + 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/internal/api/services/repo.go b/internal/api/services/repo.go index e14f7f2..cc17952 100644 --- a/internal/api/services/repo.go +++ b/internal/api/services/repo.go @@ -10,14 +10,36 @@ import ( type RepoService interface { CreateRepository(repo *models.Repository) error + GetRepository(owner, name string) (*models.Repository, error) + UpdateRepository(owner, name string, repo *models.Repository) (*models.Repository, error) + DeleteRepository(owner, name string) error + + // Collaborators (repository members). + ListCollaborators(owner, name string) ([]models.RepositoryMember, error) + AddCollaborator(owner, name, username, role string) (*models.RepositoryMember, error) + UpdateCollaborator(owner, name, username, role string) (*models.RepositoryMember, error) + RemoveCollaborator(owner, name, username string) error + + // Policies (ABAC). + ListPolicies(owner, name string) ([]models.Policy, error) + AddPolicy(owner, name, subjectType, subjectID, action, resourceID, effect string) (*models.Policy, error) + RemovePolicy(owner, name, policyID string) error } type RepoServiceImpl struct { Repository repository.RepoRepository + Members repository.RepoMemberRepository + Policies repository.PolicyRepository + Users repository.UserRepository } func NewRepoService(db *gorm.DB) RepoService { - return &RepoServiceImpl{Repository: repository.NewRepoRepository(db)} + return &RepoServiceImpl{ + Repository: repository.NewRepoRepository(db), + Members: repository.NewRepoMemberRepository(db), + Policies: repository.NewPolicyRepository(db), + Users: repository.NewUserRepository(db), + } } func (s *RepoServiceImpl) CreateRepository(repo *models.Repository) error { @@ -35,3 +57,166 @@ func (s *RepoServiceImpl) CreateRepository(repo *models.Repository) error { // so no physical_path column is stored. return s.Repository.Create(repo) } + +// resolveRepo returns the repo identified by owner+name, verifying ownership. +func (s *RepoServiceImpl) resolveRepo(owner, name string) (*models.Repository, error) { + user, err := s.Users.FindByUsername(owner) + if err != nil { + return nil, errors.New("owner not found") + } + repo, err := s.Repository.FindByUserAndName(user.ID.String(), name) + if err != nil { + return nil, errors.New("repository not found") + } + return &repo, nil +} + +func (s *RepoServiceImpl) GetRepository(owner, name string) (*models.Repository, error) { + return s.resolveRepo(owner, name) +} + +func (s *RepoServiceImpl) UpdateRepository(owner, name string, patch *models.Repository) (*models.Repository, error) { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + repo.Description = patch.Description + repo.IsPrivate = patch.IsPrivate + repo.IsActive = patch.IsActive + if err := s.Repository.Update(repo); err != nil { + return nil, err + } + return repo, nil +} + +func (s *RepoServiceImpl) DeleteRepository(owner, name string) error { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return err + } + return s.Repository.Delete(repo.ID.String()) +} + +func (s *RepoServiceImpl) ListCollaborators(owner, name string) ([]models.RepositoryMember, error) { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + return s.Members.FindByRepo(repo.ID.String()) +} + +func (s *RepoServiceImpl) AddCollaborator(owner, name, username, role string) (*models.RepositoryMember, error) { + if !models.ValidRepoRole(role) { + return nil, errors.New("invalid repository role") + } + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return nil, errors.New("user not found") + } + if repo.UserID == user.ID.String() { + return nil, errors.New("cannot add the repository owner as a collaborator") + } + member := &models.RepositoryMember{ + UserID: user.ID.String(), + RepoID: repo.ID.String(), + Role: role, + } + if err := s.Members.Add(member); err != nil { + return nil, err + } + return member, nil +} + +func (s *RepoServiceImpl) UpdateCollaborator(owner, name, username, role string) (*models.RepositoryMember, error) { + if !models.ValidRepoRole(role) { + return nil, errors.New("invalid repository role") + } + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return nil, errors.New("user not found") + } + if err := s.Members.UpdateRole(user.ID.String(), repo.ID.String(), role); err != nil { + return nil, err + } + m, err := s.Members.FindByUserAndRepo(user.ID.String(), repo.ID.String()) + if err != nil { + return nil, err + } + return &m, nil +} + +func (s *RepoServiceImpl) RemoveCollaborator(owner, name, username string) error { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return errors.New("user not found") + } + return s.Members.Remove(user.ID.String(), repo.ID.String()) +} + +func (s *RepoServiceImpl) ListPolicies(owner, name string) ([]models.Policy, error) { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + return s.Policies.FindApplicable("user", "*", "*", "repository", repo.ID.String()) +} + +func (s *RepoServiceImpl) AddPolicy(owner, name, subjectType, subjectID, action, resourceID, effect string) (*models.Policy, error) { + if effect != "allow" && effect != "deny" { + return nil, errors.New("effect must be allow or deny") + } + if subjectType != "user" && subjectType != "role" { + return nil, errors.New("subject_type must be user or role") + } + repo, err := s.resolveRepo(owner, name) + if err != nil { + return nil, err + } + pol := &models.Policy{ + SubjectType: subjectType, + SubjectID: subjectID, + Action: action, + ResourceType: "repository", + ResourceID: repo.ID.String(), + Effect: effect, + } + if err := s.Policies.Add(pol); err != nil { + return nil, err + } + return pol, nil +} + +func (s *RepoServiceImpl) RemovePolicy(owner, name, policyID string) error { + repo, err := s.resolveRepo(owner, name) + if err != nil { + return err + } + // Ensure the policy belongs to this repository before deleting. + policies, err := s.Policies.FindApplicable("user", "*", "*", "repository", repo.ID.String()) + if err != nil { + return err + } + found := false + for _, p := range policies { + if p.ID.String() == policyID { + found = true + break + } + } + if !found { + return errors.New("policy not found for this repository") + } + return s.Policies.Remove(policyID) +} diff --git a/internal/api/services/user.go b/internal/api/services/user.go index f58c36a..af77b7a 100644 --- a/internal/api/services/user.go +++ b/internal/api/services/user.go @@ -83,6 +83,10 @@ func (s *UserServiceImpl) CreateToken(userID, name, scopes string) (models.Perso return models.PersonalAccessToken{}, "", errors.New("token name is required") } + if err := models.ValidateScopes(scopes); err != nil { + return models.PersonalAccessToken{}, "", err + } + plain, prefix, hash, err := helper.GenerateToken() if err != nil { return models.PersonalAccessToken{}, "", err From b8f7c2c8ccb34638755c08d0b827433ff2488aeb Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:23 +0700 Subject: [PATCH 33/49] feat(rbac): add repository role constants and validation functions; enhance RepoRepository with additional methods --- internal/models/keys.go | 21 +++++++++++++++++++++ internal/models/repo_member.go | 18 ++++++++++++++++++ internal/repository/repo.go | 20 ++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/internal/models/keys.go b/internal/models/keys.go index 2cc731c..6b6c962 100644 --- a/internal/models/keys.go +++ b/internal/models/keys.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "strings" "time" ) @@ -61,3 +62,23 @@ func ParseScopes(raw string) []string { } return out } + +// knownScopes is the set of scopes a PAT may request. +var knownScopes = map[string]struct{}{ + ScopeRepoRead: {}, + ScopeRepoWrite: {}, + ScopeUserRead: {}, + ScopeUserWrite: {}, + ScopeAdmin: {}, +} + +// ValidateScopes returns an error if any scope in raw is not a recognized +// scope constant. The empty string (no scopes) is allowed. +func ValidateScopes(raw string) error { + for _, s := range ParseScopes(raw) { + if _, ok := knownScopes[s]; !ok { + return fmt.Errorf("unknown scope %q", s) + } + } + return nil +} diff --git a/internal/models/repo_member.go b/internal/models/repo_member.go index 6e553e6..d7ce11e 100644 --- a/internal/models/repo_member.go +++ b/internal/models/repo_member.go @@ -24,3 +24,21 @@ func (m *RepositoryMember) BeforeCreate(tx *gorm.DB) error { } func (RepositoryMember) TableName() string { return "repository_members" } + +// Repository (per-repo collaborator) role constants. +const ( + RepoRoleOwner = "owner" + RepoRoleMaintainer = "maintainer" + RepoRoleTriager = "triager" + RepoRoleReader = "reader" + RepoRoleGuest = "guest" +) + +// ValidRepoRole reports whether role is a recognized repository role. +func ValidRepoRole(role string) bool { + switch role { + case RepoRoleOwner, RepoRoleMaintainer, RepoRoleTriager, RepoRoleReader, RepoRoleGuest: + return true + } + return false +} diff --git a/internal/repository/repo.go b/internal/repository/repo.go index 50a44ef..b0462a9 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -11,6 +11,9 @@ type RepoRepository interface { Create(repo *models.Repository) error FindById(id string) (repo models.Repository, err error) FindByName(name string) (repo models.Repository, err error) + FindByUserAndName(userID, name string) (repo models.Repository, err error) + Update(repo *models.Repository) error + Delete(id string) error ExistsByName(name string) bool } @@ -60,3 +63,20 @@ func (r *RepoRepositoryImpl) ExistsByName(name string) bool { r.db.Model(&models.Repository{}).Where("name = ?", name).Count(&count) return count > 0 } + +func (r *RepoRepositoryImpl) FindByUserAndName(userID, name string) (repo models.Repository, err error) { + result := r.db.Where("user_id = ? AND name = ?", userID, name).First(&repo) + return check(result, &repo) +} + +func (r *RepoRepositoryImpl) Update(repo *models.Repository) error { + return r.db.Model(repo).Updates(map[string]interface{}{ + "description": repo.Description, + "is_private": repo.IsPrivate, + "is_active": repo.IsActive, + }).Error +} + +func (r *RepoRepositoryImpl) Delete(id string) error { + return r.db.Where("id = ?", id).Delete(&models.Repository{}).Error +} From ac97d5015f30a745ff6fb85eabf9f7a30a23f20a Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:33 +0700 Subject: [PATCH 34/49] feat(rbac): implement UploadPack method in GitHTTPController and update routes --- modules/githttp/controller.go | 5 +++++ modules/githttp/helper/options.go | 20 ++++++++++++++------ modules/githttp/routes.go | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/modules/githttp/controller.go b/modules/githttp/controller.go index fa5586c..d1a2d63 100644 --- a/modules/githttp/controller.go +++ b/modules/githttp/controller.go @@ -11,6 +11,7 @@ type ( GitHTTPController interface { InfoRefs(ctx *gin.Context) ReceivePack(ctx *gin.Context) + UploadPack(ctx *gin.Context) } gitHTTPController struct { @@ -35,3 +36,7 @@ func (c *gitHTTPController) InfoRefs(ctx *gin.Context) { func (c *gitHTTPController) ReceivePack(ctx *gin.Context) { c.service.ReceivePack(ctx) } + +func (c *gitHTTPController) UploadPack(ctx *gin.Context) { + c.service.UploadPack(ctx) +} diff --git a/modules/githttp/helper/options.go b/modules/githttp/helper/options.go index 6b2406e..81ab20c 100644 --- a/modules/githttp/helper/options.go +++ b/modules/githttp/helper/options.go @@ -33,8 +33,22 @@ func MakeOptionsFromContext(ctx *gin.Context, db *gorm.DB) Options { db: db, } + username := ctx.Param("username") + options.UserName = username + + reponame := ctx.Param("reponame") + options.RepoName = strings.TrimSuffix(reponame, ".git") + // get service type service := ctx.Query("service") + if service == "" { + // On the actual RPC endpoints (POST /git-receive-pack, + // POST /git-upload-pack) the service is in the path, not the query. + path := ctx.Request.URL.Path + service = strings.TrimPrefix(path, "/"+username+"/"+reponame) + service = strings.TrimPrefix(service, "/") + service = strings.TrimSuffix(service, "/") + } switch service { case "git-receive-pack": options.ServiceType = ServiceTypeReceivePack @@ -49,12 +63,6 @@ func MakeOptionsFromContext(ctx *gin.Context, db *gorm.DB) Options { options.ServiceType = "" } - username := ctx.Param("username") - options.UserName = username - - reponame := ctx.Param("reponame") - options.RepoName = strings.TrimSuffix(reponame, ".git") - return *options } diff --git a/modules/githttp/routes.go b/modules/githttp/routes.go index a7e4d85..ce397e3 100644 --- a/modules/githttp/routes.go +++ b/modules/githttp/routes.go @@ -17,4 +17,5 @@ func RegisterRoutes(server *gin.Engine, tx *gorm.DB) { routes.Match([]string{"GET"}, "/info/refs", controller.InfoRefs) routes.Match([]string{"POST", "OPTIONS"}, "/git-receive-pack", controller.ReceivePack) + routes.Match([]string{"POST", "OPTIONS"}, "/git-upload-pack", controller.UploadPack) } From 9355596c443685928f5b0f073315b8678133ab36 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Sun, 19 Jul 2026 23:40:47 +0700 Subject: [PATCH 35/49] feat(rbac): implement UploadPack method in GitHTTPController with error handling and logging --- modules/githttp/services/receive-pack.go | 15 +++++++- modules/githttp/services/upload-pack.go | 48 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/modules/githttp/services/receive-pack.go b/modules/githttp/services/receive-pack.go index c51fc10..7cd959c 100644 --- a/modules/githttp/services/receive-pack.go +++ b/modules/githttp/services/receive-pack.go @@ -22,6 +22,19 @@ func (s *GitServiceImpl) ReceivePack(ctx *gin.Context) { return } + storagePath, err := options.EnsureRepositoryStorage(repoID) + if err != nil { + log.Error("git receive-pack: storage error", slog.String("repo", options.RepoName), slog.String("error", err.Error())) + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + + // Authorize the push (write permission). Anonymous clients receive a 401 + // Basic challenge so Git prompts for credentials. + if !s.Authorize(ctx, options) { + return + } + log.Info("git receive-pack: push started", slog.String("repo", options.RepoName), slog.String("username", ctx.GetString("username"))) @@ -29,7 +42,7 @@ func (s *GitServiceImpl) ReceivePack(ctx *gin.Context) { ctx.Header("Content-Type", "application/x-git-receive-pack-result") cmd := git.NewCommand() - err := cmd.ReceivePackRPC(options.GetRepositoryStorage(repoID), ctx.Request.Body, ctx.Writer) + err = cmd.ReceivePackRPC(storagePath, ctx.Request.Body, ctx.Writer) if err != nil { log.Error("git receive-pack: push failed", slog.String("repo", options.RepoName), diff --git a/modules/githttp/services/upload-pack.go b/modules/githttp/services/upload-pack.go index 66cc17e..cb861eb 100644 --- a/modules/githttp/services/upload-pack.go +++ b/modules/githttp/services/upload-pack.go @@ -1,13 +1,57 @@ package services import ( + "log/slog" + "net/http" + "gitxyz/internal/logger" + "gitxyz/modules/githttp/helper" + "gitxyz/pkg/git" "github.com/gin-gonic/gin" ) func (s *GitServiceImpl) UploadPack(ctx *gin.Context) { log := logger.FromGin(ctx) - log.Warn("git upload-pack: not implemented") - ctx.AbortWithStatusJSON(501, gin.H{"error": "upload-pack not implemented"}) + options := helper.MakeOptionsFromContext(ctx, s.db) + + repoID := ctx.GetString("repo_id") + if repoID == "" { + log.Warn("git upload-pack: repository not found", slog.String("repo", options.RepoName)) + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + + storagePath, err := options.EnsureRepositoryStorage(repoID) + if err != nil { + log.Error("git upload-pack: storage error", slog.String("repo", options.RepoName), slog.String("error", err.Error())) + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + + // Authorize the fetch/clone (read permission). Anonymous clients receive a + // 401 Basic challenge so Git prompts for credentials on private repos. + if !s.Authorize(ctx, options) { + return + } + + log.Info("git upload-pack: fetch started", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username"))) + + ctx.Header("Content-Type", "application/x-git-upload-pack-result") + + cmd := git.NewCommand() + if err := cmd.UploadPackRPC(storagePath, ctx.Request.Body, ctx.Writer); err != nil { + log.Error("git upload-pack: fetch failed", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username")), + slog.String("error", err.Error())) + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + + log.Info("git upload-pack: fetch completed", + slog.String("repo", options.RepoName), + slog.String("username", ctx.GetString("username"))) } From 3e83655f1140f2f1425f8defef8572b4d8c25e16 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:00:47 +0700 Subject: [PATCH 36/49] feat(repo): implement repository listing and branch management endpoints; enhance GitQueryService for repository inspection --- internal/api/controllers/repo.go | 158 ++++++++++++++++++++++ internal/api/middlewares/rbac.go | 32 +++++ internal/api/routes/repo.go | 17 +++ internal/api/services/git.go | 217 +++++++++++++++++++++++++++++++ internal/api/services/repo.go | 13 ++ internal/repository/repo.go | 5 + pkg/git/cmd.go | 5 + 7 files changed, 447 insertions(+) create mode 100644 internal/api/services/git.go diff --git a/internal/api/controllers/repo.go b/internal/api/controllers/repo.go index 2ebd747..7ea44b3 100644 --- a/internal/api/controllers/repo.go +++ b/internal/api/controllers/repo.go @@ -1,12 +1,14 @@ package controllers import ( + "fmt" "net/http" dto "gitxyz/internal/api/dto/request" response "gitxyz/internal/api/dto/response" "gitxyz/internal/api/services" "gitxyz/internal/models" + githttpsvc "gitxyz/modules/githttp/services" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -15,9 +17,17 @@ import ( type RepoController interface { Create(ctx *gin.Context) Get(ctx *gin.Context) + List(ctx *gin.Context) Update(ctx *gin.Context) Delete(ctx *gin.Context) + ListBranches(ctx *gin.Context) + DeleteBranch(ctx *gin.Context) + ListCommits(ctx *gin.Context) + GetCommit(ctx *gin.Context) + GetContents(ctx *gin.Context) + GetFile(ctx *gin.Context) + ListCollaborators(ctx *gin.Context) AddCollaborator(ctx *gin.Context) UpdateCollaborator(ctx *gin.Context) @@ -30,6 +40,7 @@ type RepoController interface { type RepoControllerImpl struct { service services.RepoService + git services.GitQueryService db *gorm.DB } @@ -38,6 +49,7 @@ func NewRepoController(db *gorm.DB) RepoController { return &RepoControllerImpl{ service: service, + git: services.NewGitQueryService(db), db: db, } } @@ -143,6 +155,152 @@ func (c *RepoControllerImpl) Delete(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "repository deleted"}) } +func (c *RepoControllerImpl) List(ctx *gin.Context) { + owner := ctx.Param("owner") + + repos, err := c.service.ListRepositories(owner) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "repositories fetched", + "data": response.ToRepositoryResponseSlice(repos, owner), + }) +} + +// resolveReadableRepo resolves the repo and enforces read permission. +// Returns the repo on success; on failure it writes the error response and +// returns nil. +func (c *RepoControllerImpl) resolveReadableRepo(ctx *gin.Context) *models.Repository { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + + repo, err := c.service.GetRepository(owner, name) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return nil + } + + // Reuse githttp permission logic for read access. + perm := githttpsvc.NewPermission(c.db) + if !perm.CanRead(ctx, name) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return nil + } + return repo +} + +func (c *RepoControllerImpl) ListBranches(ctx *gin.Context) { + repo := c.resolveReadableRepo(ctx) + if repo == nil { + return + } + branches, err := c.git.ListBranches(repo.ID.String()) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "message": "branches fetched", + "data": branches, + }) +} + +func (c *RepoControllerImpl) DeleteBranch(ctx *gin.Context) { + owner := ctx.Param("owner") + name := ctx.Param("reponame") + branch := ctx.Param("branch") + + repo, err := c.service.GetRepository(owner, name) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + perm := githttpsvc.NewPermission(c.db) + if !perm.CanWrite(ctx, name) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + if err := c.git.DeleteBranch(repo.ID.String(), branch); err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"message": "branch deleted"}) +} + +func (c *RepoControllerImpl) ListCommits(ctx *gin.Context) { + repo := c.resolveReadableRepo(ctx) + if repo == nil { + return + } + ref := ctx.Query("ref") + limit := 30 + if l := ctx.Query("limit"); l != "" { + fmt.Sscanf(l, "%d", &limit) + } + commits, err := c.git.ListCommits(repo.ID.String(), ref, limit) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "message": "commits fetched", + "data": commits, + }) +} + +func (c *RepoControllerImpl) GetCommit(ctx *gin.Context) { + repo := c.resolveReadableRepo(ctx) + if repo == nil { + return + } + sha := ctx.Param("sha") + commit, err := c.git.GetCommit(repo.ID.String(), sha) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "message": "commit fetched", + "data": commit, + }) +} + +func (c *RepoControllerImpl) GetContents(ctx *gin.Context) { + repo := c.resolveReadableRepo(ctx) + if repo == nil { + return + } + ref := ctx.Query("ref") + path := ctx.Param("path") + entries, err := c.git.GetContents(repo.ID.String(), ref, path) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "message": "contents fetched", + "data": entries, + }) +} + +func (c *RepoControllerImpl) GetFile(ctx *gin.Context) { + repo := c.resolveReadableRepo(ctx) + if repo == nil { + return + } + ref := ctx.Query("ref") + path := ctx.Param("path") + data, err := c.git.GetFile(repo.ID.String(), ref, path) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.Data(http.StatusOK, "text/plain; charset=utf-8", data) +} + func (c *RepoControllerImpl) ListCollaborators(ctx *gin.Context) { owner := ctx.Param("owner") name := ctx.Param("reponame") diff --git a/internal/api/middlewares/rbac.go b/internal/api/middlewares/rbac.go index 3681975..d3cb07d 100644 --- a/internal/api/middlewares/rbac.go +++ b/internal/api/middlewares/rbac.go @@ -5,6 +5,7 @@ import ( "gitxyz/internal/api/services" "gitxyz/internal/models" + "gitxyz/internal/repository" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -54,10 +55,41 @@ func RequireScope(db *gorm.DB, scope string) gin.HandlerFunc { ctx.Next() return } + // The repository owner (creator) is always allowed on their own repo, + // regardless of their system role. + if isRepoOwner(ctx, db) { + ctx.Next() + return + } ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role for scope"}) } } +// isRepoOwner reports whether the authenticated user owns the repository +// referenced by the :owner/:reponame path params. +func isRepoOwner(ctx *gin.Context, db *gorm.DB) bool { + owner := ctx.Param("owner") + reponame := ctx.Param("reponame") + if owner == "" || reponame == "" { + return false + } + userRepo := repository.NewUserRepository(db) + user, err := userRepo.FindByUsername(owner) + if err != nil { + return false + } + currentUserID := ctx.GetString("user_id") + if currentUserID == "" { + return false + } + if user.ID.String() != currentUserID { + return false + } + repoRepo := repository.NewRepoRepository(db) + _, err = repoRepo.FindByUserAndName(user.ID.String(), reponame) + return err == nil +} + // CheckPolicy evaluates ABAC for a specific resource action. func CheckPolicy(db *gorm.DB, action, resourceType, resourceID string) gin.HandlerFunc { return func(ctx *gin.Context) { diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index f49a796..686e323 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -14,6 +14,11 @@ func (r *RoutesImpl) RegisterRepositories() { repos.Use(middlewares.AuthRequired()) repos.POST("", controller.Create) + // /api/users/:username/repos — list a user's repositories. + userRepos := r.engine.Group("/api/users/:username/repos") + userRepos.Use(middlewares.AuthRequired()) + userRepos.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.List) + // /api/repos/:owner/:reponame — single repository + collaborators + policies. repo := r.engine.Group("/api/repos/:owner/:reponame") repo.Use(middlewares.AuthRequired()) @@ -24,6 +29,18 @@ func (r *RoutesImpl) RegisterRepositories() { repo.PATCH("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.Update) repo.DELETE("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.Delete) + // Branches. + repo.GET("/branches", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListBranches) + repo.DELETE("/branches/:branch", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.DeleteBranch) + + // Commits. + repo.GET("/commits", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListCommits) + repo.GET("/commits/:sha", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.GetCommit) + + // Contents / file browsing. + repo.GET("/contents/*path", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.GetContents) + repo.GET("/raw/*path", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.GetFile) + // Collaborator management — repo:write scope. repo.GET("/collaborators", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListCollaborators) repo.POST("/collaborators", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.AddCollaborator) diff --git a/internal/api/services/git.go b/internal/api/services/git.go new file mode 100644 index 0000000..f3593b9 --- /dev/null +++ b/internal/api/services/git.go @@ -0,0 +1,217 @@ +package services + +import ( + "fmt" + "os/exec" + "strings" + "time" + + "gitxyz/internal/helper" + "gitxyz/pkg/git" + + "gorm.io/gorm" +) + +// GitQueryService exposes read-only repository inspection (branches, commits, +// file contents, tree listing) by shelling out to the git executable against the +// on-disk bare repository storage. +type GitQueryService interface { + ListBranches(repoID string) ([]string, error) + DeleteBranch(repoID, branch string) error + ListCommits(repoID, ref string, limit int) ([]CommitInfo, error) + GetCommit(repoID, sha string) (*CommitInfo, error) + GetContents(repoID, ref, path string) ([]ContentEntry, error) + GetFile(repoID, ref, path string) ([]byte, error) +} + +type GitQueryServiceImpl struct { + db *gorm.DB +} + +func NewGitQueryService(db *gorm.DB) GitQueryService { + return &GitQueryServiceImpl{db: db} +} + +// storagePath resolves the on-disk location of a repository from its ID. +func (s *GitQueryServiceImpl) storagePath(repoID string) string { + return helper.RepositoryStoragePath(repoID) +} + +func (s *GitQueryServiceImpl) runGit(repoID string, args ...string) (string, error) { + path := s.storagePath(repoID) + cmd := exec.Command(git.NewCommand().Executable(), "-C", path) + cmd.Args = append(cmd.Args, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, string(out)) + } + return string(out), nil +} + +func (s *GitQueryServiceImpl) ListBranches(repoID string) ([]string, error) { + out, err := s.runGit(repoID, "for-each-ref", "--format=%(refname:short)", "refs/heads") + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + result := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + result = append(result, l) + } + } + return result, nil +} + +func (s *GitQueryServiceImpl) DeleteBranch(repoID, branch string) error { + _, err := s.runGit(repoID, "update-ref", "-d", "refs/heads/"+branch) + return err +} + +// CommitInfo is a subset of git commit metadata returned by the API. +type CommitInfo struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author"` + Email string `json:"email"` + Date time.Time `json:"date"` +} + +func (s *GitQueryServiceImpl) ListCommits(repoID, ref string, limit int) ([]CommitInfo, error) { + if limit <= 0 || limit > 100 { + limit = 30 + } + args := []string{"log", fmt.Sprintf("-%d", limit), "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s", "--date=iso-strict"} + if ref != "" { + args = append(args, ref) + } + out, err := s.runGit(repoID, args...) + if err != nil { + return nil, err + } + return parseCommits(out), nil +} + +func (s *GitQueryServiceImpl) GetCommit(repoID, sha string) (*CommitInfo, error) { + out, err := s.runGit(repoID, "log", "-1", "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s", "--date=iso-strict", sha) + if err != nil { + return nil, err + } + commits := parseCommits(out) + if len(commits) == 0 { + return nil, fmt.Errorf("commit not found: %s", sha) + } + return &commits[0], nil +} + +func parseCommits(out string) []CommitInfo { + lines := strings.Split(strings.TrimSpace(out), "\n") + result := make([]CommitInfo, 0, len(lines)) + for _, l := range lines { + if l == "" { + continue + } + parts := strings.Split(l, "\x1f") + if len(parts) < 5 { + continue + } + date, _ := time.Parse(time.RFC3339, parts[3]) + result = append(result, CommitInfo{ + SHA: parts[0], + Author: parts[1], + Email: parts[2], + Date: date, + Message: parts[4], + }) + } + return result +} + +// ContentEntry describes a single entry in a repository tree (file or dir). +type ContentEntry struct { + Type string `json:"type"` // "file" or "dir" + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int64 `json:"size"` +} + +func (s *GitQueryServiceImpl) GetContents(repoID, ref, path string) ([]ContentEntry, error) { + // Use ls-tree to list a directory (or show a single file). + treeRef := ref + if treeRef == "" { + treeRef = "HEAD" + } + path = cleanPath(path) + args := []string{"ls-tree", "-l", treeRef} + if path != "" { + args = append(args, "--", path) + } + out, err := s.runGit(repoID, args...) + if err != nil { + return nil, err + } + return parseTree(out), nil +} + +func parseTree(out string) []ContentEntry { + lines := strings.Split(strings.TrimSpace(out), "\n") + result := make([]ContentEntry, 0, len(lines)) + for _, l := range lines { + if l == "" { + continue + } + // format: SP SP SP TAB + fields := strings.SplitN(l, "\t", 2) + if len(fields) != 2 { + continue + } + meta := strings.Fields(fields[0]) + if len(meta) < 4 { + continue + } + objType := meta[1] + sha := meta[2] + name := fields[1] + entryType := "file" + if objType == "tree" { + entryType = "dir" + } + // git ls-tree already returns the full path as the entry name, so use + // it directly as both Name and Path. + var size int64 + if entryType == "file" { + fmt.Sscanf(meta[3], "%d", &size) + } + result = append(result, ContentEntry{ + Type: entryType, + Name: name, + Path: name, + SHA: sha, + Size: size, + }) + } + return result +} + +func (s *GitQueryServiceImpl) GetFile(repoID, ref, path string) ([]byte, error) { + treeRef := ref + if treeRef == "" { + treeRef = "HEAD" + } + out, err := s.runGit(repoID, "show", treeRef+":"+cleanPath(path)) + if err != nil { + return nil, err + } + return []byte(out), nil +} + +// cleanPath removes a leading slash so the path is interpreted as repository +// relative rather than an absolute filesystem path by git. +func cleanPath(p string) string { + return strings.TrimPrefix(p, "/") +} + +// ensure GitQueryServiceImpl satisfies the interface (compile-time check). +var _ GitQueryService = (*GitQueryServiceImpl)(nil) diff --git a/internal/api/services/repo.go b/internal/api/services/repo.go index cc17952..1d58952 100644 --- a/internal/api/services/repo.go +++ b/internal/api/services/repo.go @@ -11,6 +11,7 @@ import ( type RepoService interface { CreateRepository(repo *models.Repository) error GetRepository(owner, name string) (*models.Repository, error) + ListRepositories(owner string) ([]models.Repository, error) UpdateRepository(owner, name string, repo *models.Repository) (*models.Repository, error) DeleteRepository(owner, name string) error @@ -75,6 +76,18 @@ func (s *RepoServiceImpl) GetRepository(owner, name string) (*models.Repository, return s.resolveRepo(owner, name) } +func (s *RepoServiceImpl) ListRepositories(owner string) ([]models.Repository, error) { + user, err := s.Users.FindByUsername(owner) + if err != nil { + return nil, errors.New("owner not found") + } + var repos []models.Repository + if err := s.Repository.(*repository.RepoRepositoryImpl).ListByUser(user.ID.String(), &repos); err != nil { + return nil, err + } + return repos, nil +} + func (s *RepoServiceImpl) UpdateRepository(owner, name string, patch *models.Repository) (*models.Repository, error) { repo, err := s.resolveRepo(owner, name) if err != nil { diff --git a/internal/repository/repo.go b/internal/repository/repo.go index b0462a9..a3456c4 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -12,6 +12,7 @@ type RepoRepository interface { FindById(id string) (repo models.Repository, err error) FindByName(name string) (repo models.Repository, err error) FindByUserAndName(userID, name string) (repo models.Repository, err error) + ListByUser(userID string, dest *[]models.Repository) error Update(repo *models.Repository) error Delete(id string) error ExistsByName(name string) bool @@ -69,6 +70,10 @@ func (r *RepoRepositoryImpl) FindByUserAndName(userID, name string) (repo models return check(result, &repo) } +func (r *RepoRepositoryImpl) ListByUser(userID string, dest *[]models.Repository) error { + return r.db.Where("user_id = ?", userID).Order("created_at DESC").Find(dest).Error +} + func (r *RepoRepositoryImpl) Update(repo *models.Repository) error { return r.db.Model(repo).Updates(map[string]interface{}{ "description": repo.Description, diff --git a/pkg/git/cmd.go b/pkg/git/cmd.go index ef5b782..f723597 100644 --- a/pkg/git/cmd.go +++ b/pkg/git/cmd.go @@ -22,6 +22,11 @@ func NewCommand() *Git { } } +// Executable returns the configured git binary path. +func (g *Git) Executable() string { + return g.executable +} + func (g *Git) IsBareRepository(path string) (bool, error) { out, err := exec.Command( g.executable, From dd919debdbc26d4694eb4e6b31c43aa3f9a6b19f Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:06:02 +0700 Subject: [PATCH 37/49] fix(permission): update user ID context key for read/write permission checks --- modules/githttp/services/permission.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/githttp/services/permission.go b/modules/githttp/services/permission.go index 81babde..2edc32c 100644 --- a/modules/githttp/services/permission.go +++ b/modules/githttp/services/permission.go @@ -71,7 +71,7 @@ func (a *PermissionImpl) CanRead(ctx *gin.Context, reponame string) bool { log.Debug("permission: read denied (no user)", slog.String("repo", reponame)) return false } - if ctx.GetString("username") == repo.UserID { + if ctx.GetString("user_id") == repo.UserID { log.Debug("permission: creator read allowed", slog.String("repo", reponame)) return true } @@ -126,7 +126,7 @@ func (a *PermissionImpl) CanWrite(ctx *gin.Context, reponame string) bool { log.Debug("permission: write denied (no user)", slog.String("repo", reponame)) return false } - if ctx.GetString("username") == repo.UserID { + if ctx.GetString("user_id") == repo.UserID { log.Debug("permission: creator write allowed", slog.String("repo", reponame)) return true } From bea9bf76cce516975ff1de31ad4c3777ab9e6f2b Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:31:42 +0700 Subject: [PATCH 38/49] feat(issues): create issues, labels, and related tables with necessary indices --- .../000008_create_issues_table.down.sql | 5 ++ .../000008_create_issues_table.up.sql | 63 +++++++++++++++++++ .../000009_add_deleted_at_to_issues.down.sql | 3 + .../000009_add_deleted_at_to_issues.up.sql | 8 +++ 4 files changed, 79 insertions(+) create mode 100644 db/migrations/000008_create_issues_table.down.sql create mode 100644 db/migrations/000008_create_issues_table.up.sql create mode 100644 db/migrations/000009_add_deleted_at_to_issues.down.sql create mode 100644 db/migrations/000009_add_deleted_at_to_issues.up.sql diff --git a/db/migrations/000008_create_issues_table.down.sql b/db/migrations/000008_create_issues_table.down.sql new file mode 100644 index 0000000..c691b9c --- /dev/null +++ b/db/migrations/000008_create_issues_table.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS issue_assignees; +DROP TABLE IF EXISTS issue_comments; +DROP TABLE IF EXISTS issue_labels; +DROP TABLE IF EXISTS labels; +DROP TABLE IF EXISTS issues; diff --git a/db/migrations/000008_create_issues_table.up.sql b/db/migrations/000008_create_issues_table.up.sql new file mode 100644 index 0000000..a698424 --- /dev/null +++ b/db/migrations/000008_create_issues_table.up.sql @@ -0,0 +1,63 @@ +CREATE TABLE issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number INTEGER NOT NULL, + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + body TEXT, + state VARCHAR(10) NOT NULL DEFAULT 'open', + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assignee_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + UNIQUE(repo_id, number), + CONSTRAINT chk_issue_state CHECK (state IN ('open', 'closed')) +); + +CREATE INDEX idx_issues_repo_id ON issues(repo_id); +CREATE INDEX idx_issues_state ON issues(state); +CREATE INDEX idx_issues_deleted_at ON issues(deleted_at); + +CREATE TABLE labels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#cccccc', + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + UNIQUE(repo_id, name) +); + +CREATE INDEX idx_labels_repo_id ON labels(repo_id); +CREATE INDEX idx_labels_deleted_at ON labels(deleted_at); + +CREATE TABLE issue_labels ( + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + PRIMARY KEY (issue_id, label_id) +); + +CREATE TABLE issue_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_issue_comments_issue_id ON issue_comments(issue_id); + +CREATE TABLE issue_assignees ( + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (issue_id, user_id) +); + +CREATE INDEX idx_issue_assignees_issue_id ON issue_assignees(issue_id); +CREATE INDEX idx_issue_assignees_user_id ON issue_assignees(user_id); diff --git a/db/migrations/000009_add_deleted_at_to_issues.down.sql b/db/migrations/000009_add_deleted_at_to_issues.down.sql new file mode 100644 index 0000000..3d19a92 --- /dev/null +++ b/db/migrations/000009_add_deleted_at_to_issues.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE issues DROP COLUMN deleted_at; +ALTER TABLE labels DROP COLUMN deleted_at; +ALTER TABLE issue_comments DROP COLUMN deleted_at; diff --git a/db/migrations/000009_add_deleted_at_to_issues.up.sql b/db/migrations/000009_add_deleted_at_to_issues.up.sql new file mode 100644 index 0000000..fa5ab45 --- /dev/null +++ b/db/migrations/000009_add_deleted_at_to_issues.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE issues ADD COLUMN deleted_at TIMESTAMPTZ; +CREATE INDEX idx_issues_deleted_at ON issues(deleted_at); + +ALTER TABLE labels ADD COLUMN deleted_at TIMESTAMPTZ; +CREATE INDEX idx_labels_deleted_at ON labels(deleted_at); + +ALTER TABLE issue_comments ADD COLUMN deleted_at TIMESTAMPTZ; +CREATE INDEX idx_issue_comments_deleted_at ON issue_comments(deleted_at); From a4a3197877a099279770262b6a234b7e65249d5f Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:35:20 +0700 Subject: [PATCH 39/49] feat(issues): implement issue management endpoints and data transfer objects --- internal/api/controllers/issue.go | 243 ++++++++++++++++++++++++ internal/api/dto/request/issue.go | 28 +++ internal/api/dto/response/issue.go | 138 ++++++++++++++ internal/api/routes/repo.go | 21 +++ internal/api/services/issue.go | 285 +++++++++++++++++++++++++++++ 5 files changed, 715 insertions(+) create mode 100644 internal/api/controllers/issue.go create mode 100644 internal/api/dto/request/issue.go create mode 100644 internal/api/dto/response/issue.go create mode 100644 internal/api/services/issue.go diff --git a/internal/api/controllers/issue.go b/internal/api/controllers/issue.go new file mode 100644 index 0000000..2436a77 --- /dev/null +++ b/internal/api/controllers/issue.go @@ -0,0 +1,243 @@ +package controllers + +import ( + "net/http" + "strconv" + + dto "gitxyz/internal/api/dto/request" + response "gitxyz/internal/api/dto/response" + "gitxyz/internal/api/services" + "gitxyz/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type IssueController interface { + List(ctx *gin.Context) + Create(ctx *gin.Context) + Get(ctx *gin.Context) + Update(ctx *gin.Context) + Delete(ctx *gin.Context) + + ListLabels(ctx *gin.Context) + CreateLabel(ctx *gin.Context) + + ListComments(ctx *gin.Context) + CreateComment(ctx *gin.Context) + + ListAssignees(ctx *gin.Context) + AddAssignee(ctx *gin.Context) + RemoveAssignee(ctx *gin.Context) +} + +type IssueControllerImpl struct { + service services.IssueService +} + +func NewIssueController(db *gorm.DB) IssueController { + return &IssueControllerImpl{ + service: services.NewIssueService(db), + } +} + +func (c *IssueControllerImpl) ownerRepo(ctx *gin.Context) (string, string) { + return ctx.Param("owner"), ctx.Param("reponame") +} + +func (c *IssueControllerImpl) List(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + issues, err := c.service.ListIssues(owner, repo) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToIssueResponseSlice(issues)) +} + +func (c *IssueControllerImpl) Create(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + var req dto.CreateIssueRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + authorID := ctx.GetString("user_id") + issue, err := c.service.CreateIssue(owner, repo, authorID, req.Title, req.Body, req.Labels) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response.ToIssueResponse(issue)) +} + +func (c *IssueControllerImpl) Get(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + issue, err := c.service.GetIssue(owner, repo, number) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToIssueResponse(issue)) +} + +func (c *IssueControllerImpl) Update(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + var req dto.UpdateIssueRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := &models.Issue{} + if req.Title != nil { + patch.Title = *req.Title + } + if req.Body != nil { + patch.Body = *req.Body + } + if req.State != nil { + patch.State = *req.State + } + + issue, err := c.service.UpdateIssue(owner, repo, number, patch, req.Labels) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToIssueResponse(issue)) +} + +func (c *IssueControllerImpl) Delete(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + if err := c.service.DeleteIssue(owner, repo, number); err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} + +func (c *IssueControllerImpl) ListLabels(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + labels, err := c.service.ListLabels(owner, repo) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToLabelResponseSlice(labels)) +} + +func (c *IssueControllerImpl) CreateLabel(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + var req dto.CreateLabelRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + label, err := c.service.CreateLabel(owner, repo, req.Name, req.Color, req.Description) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response.ToLabelResponse(label)) +} + +func (c *IssueControllerImpl) ListComments(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + comments, err := c.service.ListComments(owner, repo, number) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToIssueCommentResponseSlice(comments)) +} + +func (c *IssueControllerImpl) CreateComment(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + var req dto.CreateIssueCommentRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + authorID := ctx.GetString("user_id") + comment, err := c.service.CreateComment(owner, repo, number, authorID, req.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response.ToIssueCommentResponse(comment)) +} + +func (c *IssueControllerImpl) ListAssignees(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + users, err := c.service.ListAssignees(owner, repo, number) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToAssigneeResponseSlice(users)) +} + +func (c *IssueControllerImpl) AddAssignee(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + var req dto.AssignIssueRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := c.service.AddAssignee(owner, repo, number, req.Username); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} + +func (c *IssueControllerImpl) RemoveAssignee(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + number, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) + return + } + username := ctx.Param("username") + if err := c.service.RemoveAssignee(owner, repo, number, username); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/internal/api/dto/request/issue.go b/internal/api/dto/request/issue.go new file mode 100644 index 0000000..daff470 --- /dev/null +++ b/internal/api/dto/request/issue.go @@ -0,0 +1,28 @@ +package dto + +type CreateIssueRequest struct { + Title string `json:"title" binding:"required,max=255"` + Body string `json:"body"` + Labels []string `json:"labels"` +} + +type UpdateIssueRequest struct { + Title *string `json:"title" binding:"omitempty,max=255"` + Body *string `json:"body"` + State *string `json:"state" binding:"omitempty,oneof=open closed"` + Labels []string `json:"labels"` +} + +type CreateIssueCommentRequest struct { + Body string `json:"body" binding:"required"` +} + +type CreateLabelRequest struct { + Name string `json:"name" binding:"required,max=50"` + Color string `json:"color" binding:"omitempty,len=7"` + Description string `json:"description"` +} + +type AssignIssueRequest struct { + Username string `json:"username" binding:"required"` +} diff --git a/internal/api/dto/response/issue.go b/internal/api/dto/response/issue.go new file mode 100644 index 0000000..a01a944 --- /dev/null +++ b/internal/api/dto/response/issue.go @@ -0,0 +1,138 @@ +package dto + +import ( + "gitxyz/internal/models" + "time" +) + +type IssueResponse struct { + ID string `json:"id"` + Number int `json:"number"` + RepoID string `json:"repo_id"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Author UserResponse `json:"author"` + Assignee *UserResponse `json:"assignee,omitempty"` + Labels []LabelResponse `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at"` +} + +func ToIssueResponse(issue *models.Issue) IssueResponse { + labels := make([]LabelResponse, 0, len(issue.Labels)) + for i := range issue.Labels { + labels = append(labels, ToLabelResponse(&issue.Labels[i])) + } + + var assignee *UserResponse + if issue.Assignee != nil && issue.Assignee.ID != [16]byte{} { + a := ToUserResponse(issue.Assignee) + assignee = &a + } + + var author UserResponse + if issue.Author != nil { + author = ToUserResponse(issue.Author) + } + + return IssueResponse{ + ID: issue.ID.String(), + Number: issue.Number, + RepoID: issue.RepoID, + Title: issue.Title, + Body: issue.Body, + State: issue.State, + Author: author, + Assignee: assignee, + Labels: labels, + CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt, + ClosedAt: issue.ClosedAt, + } +} + +func ToIssueResponseSlice(list []models.Issue) []IssueResponse { + out := make([]IssueResponse, 0, len(list)) + for i := range list { + out = append(out, ToIssueResponse(&list[i])) + } + return out +} + +type LabelResponse struct { + ID string `json:"id"` + RepoID string `json:"repo_id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +func ToLabelResponse(label *models.Label) LabelResponse { + return LabelResponse{ + ID: label.ID.String(), + RepoID: label.RepoID, + Name: label.Name, + Color: label.Color, + Description: label.Description, + } +} + +func ToLabelResponseSlice(list []models.Label) []LabelResponse { + out := make([]LabelResponse, 0, len(list)) + for i := range list { + out = append(out, ToLabelResponse(&list[i])) + } + return out +} + +type IssueCommentResponse struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + Author UserResponse `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToIssueCommentResponse(comment *models.IssueComment) IssueCommentResponse { + var author UserResponse + if comment.Author != nil { + author = ToUserResponse(comment.Author) + } + return IssueCommentResponse{ + ID: comment.ID.String(), + IssueID: comment.IssueID, + Author: author, + Body: comment.Body, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + } +} + +func ToIssueCommentResponseSlice(list []models.IssueComment) []IssueCommentResponse { + out := make([]IssueCommentResponse, 0, len(list)) + for i := range list { + out = append(out, ToIssueCommentResponse(&list[i])) + } + return out +} + +type AssigneeResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` +} + +func ToAssigneeResponseSlice(list []models.User) []AssigneeResponse { + out := make([]AssigneeResponse, 0, len(list)) + for i := range list { + out = append(out, AssigneeResponse{ + ID: list[i].ID.String(), + Username: list[i].Username, + Email: list[i].Email, + }) + } + return out +} diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 686e323..9cb05ad 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -51,4 +51,25 @@ func (r *RoutesImpl) RegisterRepositories() { repo.GET("/policies", middlewares.RequireScope(r.db, models.ScopeRepoRead), controller.ListPolicies) repo.POST("/policies", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.AddPolicy) repo.DELETE("/policies/:id", middlewares.RequireScope(r.db, models.ScopeRepoWrite), controller.RemovePolicy) + + // Issues — nested under the repository group. + issueController := controllers.NewIssueController(r.db) + repo.GET("/issues", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.List) + repo.POST("/issues", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Create) + repo.GET("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.Get) + repo.PATCH("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Update) + repo.DELETE("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Delete) + + // Issue comments. + repo.GET("/issues/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListComments) + repo.POST("/issues/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateComment) + + // Labels. + repo.GET("/labels", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListLabels) + repo.POST("/labels", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateLabel) + + // Issue assignees. + repo.GET("/issues/:number/assignees", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListAssignees) + repo.POST("/issues/:number/assignees", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.AddAssignee) + repo.DELETE("/issues/:number/assignees/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.RemoveAssignee) } diff --git a/internal/api/services/issue.go b/internal/api/services/issue.go new file mode 100644 index 0000000..a6e8a3d --- /dev/null +++ b/internal/api/services/issue.go @@ -0,0 +1,285 @@ +package services + +import ( + "errors" + "time" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "gorm.io/gorm" +) + +type IssueService interface { + ListIssues(owner, name string) ([]models.Issue, error) + CreateIssue(owner, name, authorID string, title, body string, labelNames []string) (*models.Issue, error) + GetIssue(owner, name string, number int) (*models.Issue, error) + UpdateIssue(owner, name string, number int, patch *models.Issue, labelNames []string) (*models.Issue, error) + DeleteIssue(owner, name string, number int) error + + ListLabels(owner, name string) ([]models.Label, error) + CreateLabel(owner, name, labelName, color, description string) (*models.Label, error) + + ListComments(owner, name string, number int) ([]models.IssueComment, error) + CreateComment(owner, name string, number int, authorID, body string) (*models.IssueComment, error) + + ListAssignees(owner, name string, number int) ([]models.User, error) + AddAssignee(owner, name string, number int, username string) error + RemoveAssignee(owner, name string, number int, username string) error +} + +type IssueServiceImpl struct { + RepoService RepoService + Issues repository.IssueRepository + Users repository.UserRepository +} + +func NewIssueService(db *gorm.DB) IssueService { + return &IssueServiceImpl{ + RepoService: NewRepoService(db), + Issues: repository.NewIssueRepository(db), + Users: repository.NewUserRepository(db), + } +} + +func (s *IssueServiceImpl) resolve(owner, name string) (*models.Repository, error) { + return s.RepoService.GetRepository(owner, name) +} + +func (s *IssueServiceImpl) ListIssues(owner, name string) ([]models.Issue, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + var issues []models.Issue + if err := s.Issues.FindByRepo(repo.ID.String(), &issues); err != nil { + return nil, err + } + return issues, nil +} + +func (s *IssueServiceImpl) CreateIssue(owner, name, authorID, title, body string, labelNames []string) (*models.Issue, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + number, err := s.Issues.NextNumber(repo.ID.String()) + if err != nil { + return nil, err + } + + issue := &models.Issue{ + RepoID: repo.ID.String(), + Number: number, + Title: title, + Body: body, + State: models.IssueStateOpen, + AuthorID: authorID, + } + + if len(labelNames) > 0 { + if err := s.attachLabels(repo.ID.String(), issue, labelNames); err != nil { + return nil, err + } + } + + if err := s.Issues.Create(issue); err != nil { + return nil, err + } + + // Reload with associations (Author, Assignee, Labels) so the returned + // issue is fully populated for the response DTO. + created, err := s.Issues.FindByID(issue.ID.String()) + if err != nil { + return nil, err + } + return &created, nil +} + +func (s *IssueServiceImpl) GetIssue(owner, name string, number int) (*models.Issue, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + issue, err := s.Issues.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("issue not found") + } + return &issue, nil +} + +func (s *IssueServiceImpl) UpdateIssue(owner, name string, number int, patch *models.Issue, labelNames []string) (*models.Issue, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + issue, err := s.Issues.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("issue not found") + } + + if patch.Title != "" { + issue.Title = patch.Title + } + if patch.Body != "" { + issue.Body = patch.Body + } + if patch.State != "" { + issue.State = patch.State + if patch.State == models.IssueStateClosed && issue.ClosedAt == nil { + now := time.Now() + issue.ClosedAt = &now + } + if patch.State == models.IssueStateOpen { + issue.ClosedAt = nil + } + } + + if labelNames != nil { + if err := s.attachLabels(repo.ID.String(), &issue, labelNames); err != nil { + return nil, err + } + } + + if err := s.Issues.Update(&issue); err != nil { + return nil, err + } + return &issue, nil +} + +func (s *IssueServiceImpl) DeleteIssue(owner, name string, number int) error { + repo, err := s.resolve(owner, name) + if err != nil { + return err + } + issue, err := s.Issues.FindByNumber(repo.ID.String(), number) + if err != nil { + return errors.New("issue not found") + } + return s.Issues.Delete(&issue) +} + +func (s *IssueServiceImpl) ListLabels(owner, name string) ([]models.Label, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + var labels []models.Label + if err := s.Issues.FindLabels(repo.ID.String(), &labels); err != nil { + return nil, err + } + return labels, nil +} + +func (s *IssueServiceImpl) CreateLabel(owner, name, labelName, color, description string) (*models.Label, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + label := &models.Label{ + RepoID: repo.ID.String(), + Name: labelName, + Color: color, + Description: description, + } + if label.Color == "" { + label.Color = "#cccccc" + } + if err := s.Issues.CreateLabel(label); err != nil { + return nil, err + } + return label, nil +} + +func (s *IssueServiceImpl) ListComments(owner, name string, number int) ([]models.IssueComment, error) { + issue, err := s.GetIssue(owner, name, number) + if err != nil { + return nil, err + } + var comments []models.IssueComment + if err := s.Issues.FindComments(issue.ID.String(), &comments); err != nil { + return nil, err + } + return comments, nil +} + +func (s *IssueServiceImpl) CreateComment(owner, name string, number int, authorID, body string) (*models.IssueComment, error) { + issue, err := s.GetIssue(owner, name, number) + if err != nil { + return nil, err + } + comment := &models.IssueComment{ + IssueID: issue.ID.String(), + AuthorID: authorID, + Body: body, + } + if err := s.Issues.CreateComment(comment); err != nil { + return nil, err + } + + // Reload with Author preloaded so the response DTO is fully populated. + created, err := s.Issues.FindCommentByID(comment.ID.String()) + if err != nil { + return nil, err + } + return &created, nil +} + +func (s *IssueServiceImpl) ListAssignees(owner, name string, number int) ([]models.User, error) { + issue, err := s.GetIssue(owner, name, number) + if err != nil { + return nil, err + } + var users []models.User + if err := s.Issues.FindAssignees(issue.ID.String(), &users); err != nil { + return nil, err + } + return users, nil +} + +func (s *IssueServiceImpl) AddAssignee(owner, name string, number int, username string) error { + issue, err := s.GetIssue(owner, name, number) + if err != nil { + return err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return errors.New("user not found") + } + return s.Issues.AddAssignee(issue.ID.String(), user.ID.String()) +} + +func (s *IssueServiceImpl) RemoveAssignee(owner, name string, number int, username string) error { + issue, err := s.GetIssue(owner, name, number) + if err != nil { + return err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return errors.New("user not found") + } + return s.Issues.RemoveAssignee(issue.ID.String(), user.ID.String()) +} + +// attachLabels resolves label names to existing labels and assigns them to the issue. +func (s *IssueServiceImpl) attachLabels(repoID string, issue *models.Issue, labelNames []string) error { + var all []models.Label + if err := s.Issues.FindLabels(repoID, &all); err != nil { + return err + } + byName := make(map[string]models.Label, len(all)) + for i := range all { + byName[all[i].Name] = all[i] + } + + labels := make([]models.Label, 0, len(labelNames)) + for _, name := range labelNames { + label, ok := byName[name] + if !ok { + return errors.New("label not found: " + name) + } + labels = append(labels, label) + } + issue.Labels = labels + return nil +} From d01a83458a48c0bd0154156ade7a1f41a143e455 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:35:30 +0700 Subject: [PATCH 40/49] feat(issue): add issue and label models with repository interface for data access --- internal/bootstrap/init.go | 1 + internal/models/issue.go | 73 ++++++++++++++++ internal/repository/issue.go | 158 +++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 internal/models/issue.go create mode 100644 internal/repository/issue.go diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 5d7e590..2c32261 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -19,6 +19,7 @@ func Initialize() { server := gin.New() server.Use(gin.Recovery()) server.Use(middlewares.RequestID()) + server.Use(middlewares.InjectDB(db)) MakeRouter(server, db) diff --git a/internal/models/issue.go b/internal/models/issue.go new file mode 100644 index 0000000..c285695 --- /dev/null +++ b/internal/models/issue.go @@ -0,0 +1,73 @@ +package models + +import ( + "time" +) + +// Issue state constants. +const ( + IssueStateOpen = "open" + IssueStateClosed = "closed" +) + +// ValidIssueState reports whether state is a recognized issue state. +func ValidIssueState(state string) bool { + return state == IssueStateOpen || state == IssueStateClosed +} + +// Issue represents a tracked issue/bug/feature request in a repository. +type Issue struct { + Base + + Number int `json:"number" gorm:"not null;uniqueIndex:uniq_repo_issue"` + RepoID string `json:"repo_id" gorm:"type:uuid;not null;uniqueIndex:uniq_repo_issue"` + Title string `json:"title" gorm:"size:255;not null"` + Body string `json:"body" gorm:"type:text"` + State string `json:"state" gorm:"size:10;not null;default:'open'"` + AuthorID string `json:"author_id" gorm:"type:uuid;not null"` + AssigneeID *string `json:"assignee_id" gorm:"type:uuid"` + Labels []Label `json:"labels" gorm:"many2many:issue_labels;"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at"` + + Author *User `json:"author" gorm:"foreignKey:AuthorID;references:ID"` + Assignee *User `json:"assignee,omitempty" gorm:"foreignKey:AssigneeID;references:ID"` +} + +func (Issue) TableName() string { return "issues" } + +// Label is a tag that can be attached to issues. +type Label struct { + Base + + RepoID string `json:"repo_id" gorm:"type:uuid;not null"` + Name string `json:"name" gorm:"size:50;not null"` + Color string `json:"color" gorm:"size:7;not null;default:'#cccccc'"` + Description string `json:"description" gorm:"type:text"` +} + +func (Label) TableName() string { return "labels" } + +// IssueComment is a comment on an issue. +type IssueComment struct { + Base + + IssueID string `json:"issue_id" gorm:"type:uuid;not null"` + AuthorID string `json:"author_id" gorm:"type:uuid;not null"` + Body string `json:"body" gorm:"type:text;not null"` + + Author *User `json:"author" gorm:"foreignKey:AuthorID;references:ID"` +} + +func (IssueComment) TableName() string { return "issue_comments" } + +// IssueAssignee is an explicit many-to-many assignment row (used by the +// POST/DELETE /issues/:number/assignees endpoints). +type IssueAssignee struct { + IssueID string `json:"issue_id" gorm:"type:uuid;not null;uniqueIndex:uniq_issue_assignee"` + UserID string `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:uniq_issue_assignee"` + CreatedAt time.Time `json:"created_at"` +} + +func (IssueAssignee) TableName() string { return "issue_assignees" } diff --git a/internal/repository/issue.go b/internal/repository/issue.go new file mode 100644 index 0000000..d5736e8 --- /dev/null +++ b/internal/repository/issue.go @@ -0,0 +1,158 @@ +package repository + +import ( + "gitxyz/internal/models" + + "gorm.io/gorm" +) + +// IssueRepository provides data access for issues, labels, comments and assignees. +type IssueRepository interface { + // Issues + Create(issue *models.Issue) error + FindByRepo(repoID string, dest *[]models.Issue) error + FindByNumber(repoID string, number int) (models.Issue, error) + FindByID(id string) (models.Issue, error) + Update(issue *models.Issue) error + Delete(issue *models.Issue) error + NextNumber(repoID string) (int, error) + + // Labels + CreateLabel(label *models.Label) error + FindLabels(repoID string, dest *[]models.Label) error + FindLabelByID(repoID, labelID string) (models.Label, error) + + // Comments + CreateComment(comment *models.IssueComment) error + FindComments(issueID string, dest *[]models.IssueComment) error + FindCommentByID(id string) (models.IssueComment, error) + + // Assignees + AddAssignee(issueID, userID string) error + RemoveAssignee(issueID, userID string) error + FindAssignees(issueID string, dest *[]models.User) error +} + +type IssueRepositoryImpl struct { + db *gorm.DB +} + +func NewIssueRepository(db *gorm.DB) *IssueRepositoryImpl { + return &IssueRepositoryImpl{db: db} +} + +func (r *IssueRepositoryImpl) Create(issue *models.Issue) error { + return r.db.Create(issue).Error +} + +func (r *IssueRepositoryImpl) FindByRepo(repoID string, dest *[]models.Issue) error { + return r.db. + Preload("Author"). + Preload("Assignee"). + Preload("Labels"). + Where("repo_id = ?", repoID). + Order("number ASC"). + Find(dest).Error +} + +func (r *IssueRepositoryImpl) FindByNumber(repoID string, number int) (models.Issue, error) { + var issue models.Issue + err := r.db. + Preload("Author"). + Preload("Assignee"). + Preload("Labels"). + Where("repo_id = ? AND number = ?", repoID, number). + First(&issue).Error + return issue, err +} + +func (r *IssueRepositoryImpl) FindByID(id string) (models.Issue, error) { + var issue models.Issue + err := r.db. + Preload("Author"). + Preload("Assignee"). + Preload("Labels"). + Where("id = ?", id). + First(&issue).Error + return issue, err +} + +func (r *IssueRepositoryImpl) Update(issue *models.Issue) error { + return r.db.Model(issue).Updates(map[string]interface{}{ + "title": issue.Title, + "body": issue.Body, + "state": issue.State, + "assignee_id": issue.AssigneeID, + "closed_at": issue.ClosedAt, + }).Error +} + +func (r *IssueRepositoryImpl) Delete(issue *models.Issue) error { + return r.db.Delete(issue).Error +} + +func (r *IssueRepositoryImpl) NextNumber(repoID string) (int, error) { + var max int + err := r.db. + Model(&models.Issue{}). + Where("repo_id = ?", repoID). + Select("COALESCE(MAX(number), 0)"). + Scan(&max).Error + if err != nil { + return 0, err + } + return max + 1, nil +} + +func (r *IssueRepositoryImpl) CreateLabel(label *models.Label) error { + return r.db.Create(label).Error +} + +func (r *IssueRepositoryImpl) FindLabels(repoID string, dest *[]models.Label) error { + return r.db.Where("repo_id = ?", repoID).Order("name ASC").Find(dest).Error +} + +func (r *IssueRepositoryImpl) FindLabelByID(repoID, labelID string) (models.Label, error) { + var label models.Label + err := r.db.Where("repo_id = ? AND id = ?", repoID, labelID).First(&label).Error + return label, err +} + +func (r *IssueRepositoryImpl) CreateComment(comment *models.IssueComment) error { + return r.db.Create(comment).Error +} + +func (r *IssueRepositoryImpl) FindComments(issueID string, dest *[]models.IssueComment) error { + return r.db. + Preload("Author"). + Where("issue_id = ?", issueID). + Order("created_at ASC"). + Find(dest).Error +} + +func (r *IssueRepositoryImpl) FindCommentByID(id string) (models.IssueComment, error) { + var comment models.IssueComment + err := r.db. + Preload("Author"). + Where("id = ?", id). + First(&comment).Error + return comment, err +} + +func (r *IssueRepositoryImpl) AddAssignee(issueID, userID string) error { + assignee := models.IssueAssignee{IssueID: issueID, UserID: userID} + return r.db.Create(&assignee).Error +} + +func (r *IssueRepositoryImpl) RemoveAssignee(issueID, userID string) error { + return r.db. + Where("issue_id = ? AND user_id = ?", issueID, userID). + Delete(&models.IssueAssignee{}).Error +} + +func (r *IssueRepositoryImpl) FindAssignees(issueID string, dest *[]models.User) error { + return r.db. + Joins("JOIN issue_assignees ON issue_assignees.user_id = users.id"). + Where("issue_assignees.issue_id = ?", issueID). + Find(dest).Error +} From 48df5865260fd625cd727b736a73e23011effd5a Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:35:38 +0700 Subject: [PATCH 41/49] feat(auth): enhance user ID resolution in AuthRequired middleware and add InjectDB function --- internal/api/middlewares/auth.go | 25 ++++++++++++++++++++- modules/githttp/services/permission_test.go | 4 +++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/api/middlewares/auth.go b/internal/api/middlewares/auth.go index 84b4837..c32527a 100644 --- a/internal/api/middlewares/auth.go +++ b/internal/api/middlewares/auth.go @@ -5,10 +5,21 @@ import ( "strings" "gitxyz/internal/api/auth" + "gitxyz/internal/repository" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) +// InjectDB stores the database handle in the request context so other +// middlewares (e.g. AuthRequired) can resolve users without re-capturing db. +func InjectDB(db *gorm.DB) gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Set("db", db) + ctx.Next() + } +} + func AuthRequired() gin.HandlerFunc { return func(ctx *gin.Context) { authorizationHeader := ctx.GetHeader("Authorization") @@ -29,7 +40,19 @@ func AuthRequired() gin.HandlerFunc { return } - ctx.Set("user_id", claims.UserID) + userID := claims.UserID + // Backwards-compat: tokens issued before the user_id claim existed + // carry an empty UserID but a valid Username. Resolve the ID from the + // database so downstream code (e.g. issue author) works correctly. + if userID == "" && claims.Username != "" { + if db, ok := ctx.MustGet("db").(*gorm.DB); ok { + if user, err := repository.NewUserRepository(db).FindByUsername(claims.Username); err == nil { + userID = user.ID.String() + } + } + } + + ctx.Set("user_id", userID) ctx.Set("username", claims.Username) ctx.Set("email", claims.Email) ctx.Set("role", claims.Role) diff --git a/modules/githttp/services/permission_test.go b/modules/githttp/services/permission_test.go index ffea0fe..c72c582 100644 --- a/modules/githttp/services/permission_test.go +++ b/modules/githttp/services/permission_test.go @@ -141,12 +141,14 @@ func TestCreatorCanAccessPrivateRepo(t *testing.T) { db := setupPermDB(t) userRepo := repository.NewUserRepository(db) _ = userRepo.Create(&models.User{Username: "owner1", Email: "owner1@example.com", Role: "user"}) + owner, _ := userRepo.FindByUsername("owner1") repoRepo := repository.NewRepoRepository(db) - _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: "owner1"}) + _ = repoRepo.Create(&models.Repository{Name: "priv", IsPrivate: true, UserID: owner.ID.String()}) perm := NewPermission(db) ctx, _ := gin.CreateTestContext(nil) ctx.Set("username", "owner1") + ctx.Set("user_id", owner.ID.String()) ctx.Set("role", "user") ctx.Set("token_type", "jwt") ctx.Set("token_scopes", "") From 7db3d7cc23374b673572ac6ea6b4e00ebf1d2f53 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 00:40:40 +0700 Subject: [PATCH 42/49] feat(issues): refactor issue routes for improved organization and readability --- internal/api/routes/repo.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 9cb05ad..04246f1 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -54,22 +54,28 @@ func (r *RoutesImpl) RegisterRepositories() { // Issues — nested under the repository group. issueController := controllers.NewIssueController(r.db) - repo.GET("/issues", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.List) - repo.POST("/issues", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Create) - repo.GET("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.Get) - repo.PATCH("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Update) - repo.DELETE("/issues/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Delete) + + // Issues collection + single issue. + issues := repo.Group("/issues") + issues.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.List) + issues.POST("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Create) + issues.GET("/:number", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.Get) + issues.PATCH("/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Update) + issues.DELETE("/:number", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.Delete) // Issue comments. - repo.GET("/issues/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListComments) - repo.POST("/issues/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateComment) + issueComments := repo.Group("/issues/:number/comments") + issueComments.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListComments) + issueComments.POST("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateComment) // Labels. - repo.GET("/labels", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListLabels) - repo.POST("/labels", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateLabel) + labels := repo.Group("/labels") + labels.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListLabels) + labels.POST("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.CreateLabel) // Issue assignees. - repo.GET("/issues/:number/assignees", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListAssignees) - repo.POST("/issues/:number/assignees", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.AddAssignee) - repo.DELETE("/issues/:number/assignees/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.RemoveAssignee) + assignees := repo.Group("/issues/:number/assignees") + assignees.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListAssignees) + assignees.POST("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.AddAssignee) + assignees.DELETE("/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.RemoveAssignee) } From 8c8518319ecaf85db75dcf2b2a1a80b3fa882294 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:08:42 +0700 Subject: [PATCH 43/49] feat(patches): add migration scripts for patch requests and related tables --- .../000010_create_patches_table.down.sql | 8 ++ .../000010_create_patches_table.up.sql | 101 ++++++++++++++++++ ...11_add_deleted_at_to_patch_tables.down.sql | 3 + ...0011_add_deleted_at_to_patch_tables.up.sql | 9 ++ 4 files changed, 121 insertions(+) create mode 100644 db/migrations/000010_create_patches_table.down.sql create mode 100644 db/migrations/000010_create_patches_table.up.sql create mode 100644 db/migrations/000011_add_deleted_at_to_patch_tables.down.sql create mode 100644 db/migrations/000011_add_deleted_at_to_patch_tables.up.sql diff --git a/db/migrations/000010_create_patches_table.down.sql b/db/migrations/000010_create_patches_table.down.sql new file mode 100644 index 0000000..571663c --- /dev/null +++ b/db/migrations/000010_create_patches_table.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS patch_comments; +DROP TABLE IF EXISTS patch_reviews; +DROP TABLE IF EXISTS patch_reviewers; +DROP TABLE IF EXISTS patch_files; +DROP TABLE IF EXISTS patch_commits; +DROP TABLE IF EXISTS patch_requests; + +ALTER TABLE repositories DROP COLUMN IF EXISTS last_item_number; diff --git a/db/migrations/000010_create_patches_table.up.sql b/db/migrations/000010_create_patches_table.up.sql new file mode 100644 index 0000000..b6a3050 --- /dev/null +++ b/db/migrations/000010_create_patches_table.up.sql @@ -0,0 +1,101 @@ +-- Add atomic counter column for patch request numbering (independent from issues). +ALTER TABLE repositories ADD COLUMN last_item_number INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE patch_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT, + source_branch VARCHAR(255) NOT NULL, + target_branch VARCHAR(255) NOT NULL, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state VARCHAR(10) NOT NULL DEFAULT 'open', + base_sha CHAR(40), + head_sha CHAR(40), + merge_commit_sha CHAR(40), + is_mergeable BOOLEAN, + merged_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + UNIQUE(repo_id, number), + CONSTRAINT chk_patch_state CHECK (state IN ('open', 'merged', 'closed')) +); + +CREATE INDEX idx_patch_requests_repo_id ON patch_requests(repo_id); +CREATE INDEX idx_patch_requests_state ON patch_requests(state); +CREATE INDEX idx_patch_requests_deleted_at ON patch_requests(deleted_at); + +CREATE TABLE patch_commits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patch_id UUID NOT NULL REFERENCES patch_requests(id) ON DELETE CASCADE, + sha CHAR(40) NOT NULL, + message TEXT, + author_name VARCHAR(255), + author_email VARCHAR(255), + author_date TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_patch_commits_patch_id ON patch_commits(patch_id); +CREATE INDEX idx_patch_commits_deleted_at ON patch_commits(deleted_at); + +CREATE TABLE patch_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patch_id UUID NOT NULL REFERENCES patch_requests(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + diff TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_patch_files_patch_id ON patch_files(patch_id); +CREATE INDEX idx_patch_files_deleted_at ON patch_files(deleted_at); + +CREATE TABLE patch_reviewers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patch_id UUID NOT NULL REFERENCES patch_requests(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + UNIQUE(patch_id, user_id) +); + +CREATE INDEX idx_patch_reviewers_patch_id ON patch_reviewers(patch_id); +CREATE INDEX idx_patch_reviewers_user_id ON patch_reviewers(user_id); + +CREATE TABLE patch_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patch_id UUID NOT NULL REFERENCES patch_requests(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state VARCHAR(20) NOT NULL, + body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_patch_reviews_patch_id ON patch_reviews(patch_id); +CREATE INDEX idx_patch_reviews_deleted_at ON patch_reviews(deleted_at); + +CREATE TABLE patch_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patch_id UUID NOT NULL REFERENCES patch_requests(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + file_path TEXT, + line INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_patch_comments_patch_id ON patch_comments(patch_id); +CREATE INDEX idx_patch_comments_deleted_at ON patch_comments(deleted_at); diff --git a/db/migrations/000011_add_deleted_at_to_patch_tables.down.sql b/db/migrations/000011_add_deleted_at_to_patch_tables.down.sql new file mode 100644 index 0000000..b7f5cb4 --- /dev/null +++ b/db/migrations/000011_add_deleted_at_to_patch_tables.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE patch_commits DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE patch_files DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE patch_reviewers DROP COLUMN IF EXISTS deleted_at; diff --git a/db/migrations/000011_add_deleted_at_to_patch_tables.up.sql b/db/migrations/000011_add_deleted_at_to_patch_tables.up.sql new file mode 100644 index 0000000..32582ad --- /dev/null +++ b/db/migrations/000011_add_deleted_at_to_patch_tables.up.sql @@ -0,0 +1,9 @@ +-- Add missing deleted_at columns to patch snapshot/reviewer tables. +-- These tables embed models.Base (gorm.DeletedAt) so GORM expects the column. +ALTER TABLE patch_commits ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE patch_files ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE patch_reviewers ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_patch_commits_deleted_at ON patch_commits(deleted_at); +CREATE INDEX IF NOT EXISTS idx_patch_files_deleted_at ON patch_files(deleted_at); +CREATE INDEX IF NOT EXISTS idx_patch_reviewers_deleted_at ON patch_reviewers(deleted_at); From d9b4a4b0ca07b8a79464761c70fee516884c0a75 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:08:52 +0700 Subject: [PATCH 44/49] feat(patch): implement patch controller and DTOs for patch requests --- internal/api/controllers/patch.go | 297 +++++++++++++++++++++++++++++ internal/api/dto/request/patch.go | 29 +++ internal/api/dto/response/patch.go | 188 ++++++++++++++++++ internal/api/dto/response/user.go | 8 + 4 files changed, 522 insertions(+) create mode 100644 internal/api/controllers/patch.go create mode 100644 internal/api/dto/request/patch.go create mode 100644 internal/api/dto/response/patch.go diff --git a/internal/api/controllers/patch.go b/internal/api/controllers/patch.go new file mode 100644 index 0000000..fecb73a --- /dev/null +++ b/internal/api/controllers/patch.go @@ -0,0 +1,297 @@ +package controllers + +import ( + "net/http" + "strconv" + + dto "gitxyz/internal/api/dto/request" + response "gitxyz/internal/api/dto/response" + "gitxyz/internal/api/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type PatchController interface { + List(ctx *gin.Context) + Create(ctx *gin.Context) + Get(ctx *gin.Context) + Update(ctx *gin.Context) + Refresh(ctx *gin.Context) + Merge(ctx *gin.Context) + + ListCommits(ctx *gin.Context) + ListFiles(ctx *gin.Context) + + AssignReviewer(ctx *gin.Context) + UnassignReviewer(ctx *gin.Context) + ListReviewers(ctx *gin.Context) + + SubmitReview(ctx *gin.Context) + ListReviews(ctx *gin.Context) + + CreateComment(ctx *gin.Context) + ListComments(ctx *gin.Context) +} + +type PatchControllerImpl struct { + service services.PatchService +} + +func NewPatchController(db *gorm.DB) PatchController { + return &PatchControllerImpl{ + service: services.NewPatchService(db), + } +} + +func (c *PatchControllerImpl) ownerRepo(ctx *gin.Context) (string, string) { + return ctx.Param("owner"), ctx.Param("reponame") +} + +func (c *PatchControllerImpl) number(ctx *gin.Context) (int, bool) { + n, err := strconv.Atoi(ctx.Param("number")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch number"}) + return 0, false + } + return n, true +} + +func (c *PatchControllerImpl) List(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + patches, err := c.service.ListPatches(owner, repo) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchResponseSlice(patches)) +} + +func (c *PatchControllerImpl) Create(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + var req dto.CreatePatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + authorID := ctx.GetString("user_id") + patch, err := c.service.CreatePatch(owner, repo, authorID, req.Title, req.Body, req.SourceBranch, req.TargetBranch) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) Get(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + patch, err := c.service.GetPatch(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) Update(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + var req dto.UpdatePatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + title, body, state := "", "", "" + if req.Title != nil { + title = *req.Title + } + if req.Body != nil { + body = *req.Body + } + if req.State != nil { + state = *req.State + } + patch, err := c.service.UpdatePatch(owner, repo, n, title, body, state) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) Refresh(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + patch, err := c.service.RefreshPatch(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) Merge(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + mergerID := ctx.GetString("user_id") + patch, err := c.service.MergePatch(owner, repo, n, mergerID) + if err != nil { + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) ListCommits(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + commits, err := c.service.ListCommits(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchCommitResponseSlice(commits)) +} + +func (c *PatchControllerImpl) ListFiles(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + files, err := c.service.ListFiles(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchFileResponseSlice(files)) +} + +func (c *PatchControllerImpl) AssignReviewer(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + var req dto.AssignPatchReviewerRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := c.service.AssignReviewer(owner, repo, n, req.Username); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} + +func (c *PatchControllerImpl) UnassignReviewer(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + username := ctx.Param("username") + if err := c.service.UnassignReviewer(owner, repo, n, username); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} + +func (c *PatchControllerImpl) ListReviewers(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + reviewers, err := c.service.ListReviewers(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToUserResponseSlice(reviewers)) +} + +func (c *PatchControllerImpl) SubmitReview(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + var req dto.SubmitPatchReviewRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + authorID := ctx.GetString("user_id") + if err := c.service.SubmitReview(owner, repo, n, authorID, req.State, req.Body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.Status(http.StatusNoContent) +} + +func (c *PatchControllerImpl) ListReviews(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + reviews, err := c.service.ListReviews(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchReviewResponseSlice(reviews)) +} + +func (c *PatchControllerImpl) CreateComment(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + var req dto.CreatePatchCommentRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + authorID := ctx.GetString("user_id") + comment, err := c.service.CreateComment(owner, repo, n, authorID, req.Body, req.FilePath, req.Line) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response.ToPatchCommentResponse(comment)) +} + +func (c *PatchControllerImpl) ListComments(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + comments, err := c.service.ListComments(owner, repo, n) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, response.ToPatchCommentResponseSlice(comments)) +} diff --git a/internal/api/dto/request/patch.go b/internal/api/dto/request/patch.go new file mode 100644 index 0000000..f1a2d4d --- /dev/null +++ b/internal/api/dto/request/patch.go @@ -0,0 +1,29 @@ +package dto + +type CreatePatchRequest struct { + Title string `json:"title" binding:"required,max=255"` + Body string `json:"body"` + SourceBranch string `json:"source_branch" binding:"required"` + TargetBranch string `json:"target_branch" binding:"required"` +} + +type UpdatePatchRequest struct { + Title *string `json:"title" binding:"omitempty,max=255"` + Body *string `json:"body"` + State *string `json:"state" binding:"omitempty,oneof=open merged closed"` +} + +type SubmitPatchReviewRequest struct { + State string `json:"state" binding:"required,oneof=approved changes_requested commented"` + Body string `json:"body"` +} + +type CreatePatchCommentRequest struct { + Body string `json:"body" binding:"required"` + FilePath string `json:"file_path"` + Line *int `json:"line"` +} + +type AssignPatchReviewerRequest struct { + Username string `json:"username" binding:"required"` +} diff --git a/internal/api/dto/response/patch.go b/internal/api/dto/response/patch.go new file mode 100644 index 0000000..94f3d8f --- /dev/null +++ b/internal/api/dto/response/patch.go @@ -0,0 +1,188 @@ +package dto + +import ( + "gitxyz/internal/models" + "time" +) + +type PatchResponse struct { + ID string `json:"id"` + RepoID string `json:"repo_id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Author UserResponse `json:"author"` + State string `json:"state"` + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` + MergeCommitSHA *string `json:"merge_commit_sha"` + IsMergeable *bool `json:"is_mergeable"` + MergedAt *time.Time `json:"merged_at"` + ClosedAt *time.Time `json:"closed_at"` + Reviewers []UserResponse `json:"reviewers"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToPatchResponse(patch *models.PatchRequest) PatchResponse { + reviewers := make([]UserResponse, 0, len(patch.Reviewers)) + for i := range patch.Reviewers { + if patch.Reviewers[i].User != nil && patch.Reviewers[i].User.ID != [16]byte{} { + reviewers = append(reviewers, ToUserResponse(patch.Reviewers[i].User)) + } + } + + var author UserResponse + if patch.Author != nil { + author = ToUserResponse(patch.Author) + } + + return PatchResponse{ + ID: patch.ID.String(), + RepoID: patch.RepoID, + Number: patch.Number, + Title: patch.Title, + Body: patch.Body, + SourceBranch: patch.SourceBranch, + TargetBranch: patch.TargetBranch, + Author: author, + State: patch.State, + BaseSHA: patch.BaseSHA, + HeadSHA: patch.HeadSHA, + MergeCommitSHA: patch.MergeCommitSHA, + IsMergeable: patch.IsMergeable, + MergedAt: patch.MergedAt, + ClosedAt: patch.ClosedAt, + Reviewers: reviewers, + CreatedAt: patch.CreatedAt, + UpdatedAt: patch.UpdatedAt, + } +} + +func ToPatchResponseSlice(list []models.PatchRequest) []PatchResponse { + out := make([]PatchResponse, 0, len(list)) + for i := range list { + out = append(out, ToPatchResponse(&list[i])) + } + return out +} + +type PatchCommitResponse struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author"` + Email string `json:"email"` + Date time.Time `json:"date"` +} + +func ToPatchCommitResponse(c *models.PatchCommit) PatchCommitResponse { + return PatchCommitResponse{ + SHA: c.SHA, + Message: c.Message, + Author: c.AuthorName, + Email: c.AuthorEmail, + Date: c.AuthorDate, + } +} + +func ToPatchCommitResponseSlice(list []models.PatchCommit) []PatchCommitResponse { + out := make([]PatchCommitResponse, 0, len(list)) + for i := range list { + out = append(out, ToPatchCommitResponse(&list[i])) + } + return out +} + +type PatchFileResponse struct { + FilePath string `json:"file_path"` + Status string `json:"status"` + Diff string `json:"diff"` +} + +func ToPatchFileResponse(f *models.PatchFile) PatchFileResponse { + return PatchFileResponse{ + FilePath: f.FilePath, + Status: f.Status, + Diff: f.Diff, + } +} + +func ToPatchFileResponseSlice(list []models.PatchFile) []PatchFileResponse { + out := make([]PatchFileResponse, 0, len(list)) + for i := range list { + out = append(out, ToPatchFileResponse(&list[i])) + } + return out +} + +type PatchReviewResponse struct { + ID string `json:"id"` + PatchID string `json:"patch_id"` + Author UserResponse `json:"author"` + State string `json:"state"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToPatchReviewResponse(r *models.PatchReview) PatchReviewResponse { + var author UserResponse + if r.Author != nil { + author = ToUserResponse(r.Author) + } + return PatchReviewResponse{ + ID: r.ID.String(), + PatchID: r.PatchID, + Author: author, + State: r.State, + Body: r.Body, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func ToPatchReviewResponseSlice(list []models.PatchReview) []PatchReviewResponse { + out := make([]PatchReviewResponse, 0, len(list)) + for i := range list { + out = append(out, ToPatchReviewResponse(&list[i])) + } + return out +} + +type PatchCommentResponse struct { + ID string `json:"id"` + PatchID string `json:"patch_id"` + Author UserResponse `json:"author"` + Body string `json:"body"` + FilePath *string `json:"file_path"` + Line *int `json:"line"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToPatchCommentResponse(c *models.PatchComment) PatchCommentResponse { + var author UserResponse + if c.Author != nil { + author = ToUserResponse(c.Author) + } + return PatchCommentResponse{ + ID: c.ID.String(), + PatchID: c.PatchID, + Author: author, + Body: c.Body, + FilePath: c.FilePath, + Line: c.Line, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func ToPatchCommentResponseSlice(list []models.PatchComment) []PatchCommentResponse { + out := make([]PatchCommentResponse, 0, len(list)) + for i := range list { + out = append(out, ToPatchCommentResponse(&list[i])) + } + return out +} diff --git a/internal/api/dto/response/user.go b/internal/api/dto/response/user.go index 51dfcd6..638d460 100644 --- a/internal/api/dto/response/user.go +++ b/internal/api/dto/response/user.go @@ -84,3 +84,11 @@ func ToUserResponse(user *models.User) UserResponse { LastLoginAt: user.LastLoginAt, } } + +func ToUserResponseSlice(list []models.User) []UserResponse { + out := make([]UserResponse, 0, len(list)) + for i := range list { + out = append(out, ToUserResponse(&list[i])) + } + return out +} From 9c3836a32819dc2ad95d51f2654fa86c4d870435 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:09:04 +0700 Subject: [PATCH 45/49] feat: implement patch request management - Added new routes for handling patch requests, including listing, creating, updating, refreshing, and merging patches. - Introduced a new PatchService to manage patch-related operations, including commit and file snapshots, reviewer assignments, reviews, and comments. - Created models for PatchRequest, PatchCommit, PatchFile, PatchReviewer, PatchReview, and PatchComment to represent patch data structures. - Implemented a PatchRepository for data access, including methods for creating, updating, and querying patches and their associated data. - Enhanced GitQueryService with methods for checking mergeability and performing merges. - Added error handling for merge conflicts and validation for branch existence during patch creation. --- internal/api/middlewares/rbac.go | 81 +++++ internal/api/routes/repo.go | 29 ++ internal/api/services/git.go | 196 +++++++++++ internal/api/services/patch.go | 538 +++++++++++++++++++++++++++++++ internal/models/patch.go | 148 +++++++++ internal/repository/patch.go | 242 ++++++++++++++ 6 files changed, 1234 insertions(+) create mode 100644 internal/api/services/patch.go create mode 100644 internal/models/patch.go create mode 100644 internal/repository/patch.go diff --git a/internal/api/middlewares/rbac.go b/internal/api/middlewares/rbac.go index d3cb07d..228f81f 100644 --- a/internal/api/middlewares/rbac.go +++ b/internal/api/middlewares/rbac.go @@ -109,3 +109,84 @@ func CheckPolicy(db *gorm.DB, action, resourceType, resourceID string) gin.Handl ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "policy denied"}) } } + +// resolveRepoID returns the repository UUID for the :owner/:reponame path. +func resolveRepoID(ctx *gin.Context, db *gorm.DB) (string, bool) { + owner := ctx.Param("owner") + reponame := ctx.Param("reponame") + if owner == "" || reponame == "" { + return "", false + } + userRepo := repository.NewUserRepository(db) + user, err := userRepo.FindByUsername(owner) + if err != nil { + return "", false + } + repoRepo := repository.NewRepoRepository(db) + repo, err := repoRepo.FindByUserAndName(user.ID.String(), reponame) + if err != nil { + return "", false + } + return repo.ID.String(), true +} + +// memberRole returns the repository_members role for the current user, or "". +func memberRole(ctx *gin.Context, db *gorm.DB, repoID string) string { + userID := ctx.GetString("user_id") + if userID == "" { + return "" + } + var role string + err := db.Model(&models.RepositoryMember{}). + Where("user_id = ? AND repo_id = ?", userID, repoID). + Select("role"). + Scan(&role).Error + if err != nil { + return "" + } + return role +} + +// MaintainerOrOwner aborts with 403 unless the authenticated user is the repo +// owner (creator) or a repository member with role owner/maintainer. +func MaintainerOrOwner(db *gorm.DB) gin.HandlerFunc { + return func(ctx *gin.Context) { + repoID, ok := resolveRepoID(ctx, db) + if !ok { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + // Repo creator is always allowed. + if isRepoOwner(ctx, db) { + ctx.Next() + return + } + role := memberRole(ctx, db, repoID) + if role == "owner" || role == "maintainer" { + ctx.Next() + return + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "maintainer or owner required"}) + } +} + +// CollaboratorOrOwner aborts with 403 unless the authenticated user is the repo +// owner (creator) or any repository member (owner/maintainer/triager/reader/guest). +func CollaboratorOrOwner(db *gorm.DB) gin.HandlerFunc { + return func(ctx *gin.Context) { + repoID, ok := resolveRepoID(ctx, db) + if !ok { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return + } + if isRepoOwner(ctx, db) { + ctx.Next() + return + } + if memberRole(ctx, db, repoID) != "" { + ctx.Next() + return + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "collaborator or owner required"}) + } +} diff --git a/internal/api/routes/repo.go b/internal/api/routes/repo.go index 04246f1..c57f214 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -78,4 +78,33 @@ func (r *RoutesImpl) RegisterRepositories() { assignees.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), issueController.ListAssignees) assignees.POST("", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.AddAssignee) assignees.DELETE("/:username", middlewares.RequireScope(r.db, models.ScopeRepoWrite), issueController.RemoveAssignee) + + // Patch Requests — nested under the repository group. + patchController := controllers.NewPatchController(r.db) + + // Patch collection + single patch. + patches := repo.Group("/patches") + patches.GET("", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.List) + patches.POST("", middlewares.CollaboratorOrOwner(r.db), patchController.Create) + patches.GET("/:number", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.Get) + patches.PATCH("/:number", middlewares.CollaboratorOrOwner(r.db), patchController.Update) + patches.POST("/:number/refresh", middlewares.CollaboratorOrOwner(r.db), patchController.Refresh) + patches.POST("/:number/merge", middlewares.MaintainerOrOwner(r.db), patchController.Merge) + + // Patch snapshot views. + patches.GET("/:number/commits", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.ListCommits) + patches.GET("/:number/files", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.ListFiles) + + // Patch reviewers. + patches.POST("/:number/reviewers", middlewares.CollaboratorOrOwner(r.db), patchController.AssignReviewer) + patches.DELETE("/:number/reviewers/:username", middlewares.CollaboratorOrOwner(r.db), patchController.UnassignReviewer) + patches.GET("/:number/reviewers", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.ListReviewers) + + // Patch reviews. + patches.GET("/:number/reviews", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.ListReviews) + patches.POST("/:number/reviews", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.SubmitReview) + + // Patch comments (public to authenticated readers). + patches.GET("/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.ListComments) + patches.POST("/:number/comments", middlewares.RequireScope(r.db, models.ScopeRepoRead), patchController.CreateComment) } diff --git a/internal/api/services/git.go b/internal/api/services/git.go index f3593b9..469557d 100644 --- a/internal/api/services/git.go +++ b/internal/api/services/git.go @@ -1,6 +1,7 @@ package services import ( + "errors" "fmt" "os/exec" "strings" @@ -12,6 +13,9 @@ import ( "gorm.io/gorm" ) +// ErrMergeConflict indicates a patch cannot be merged due to conflicts. +var ErrMergeConflict = errors.New("merge conflict") + // GitQueryService exposes read-only repository inspection (branches, commits, // file contents, tree listing) by shelling out to the git executable against the // on-disk bare repository storage. @@ -22,6 +26,22 @@ type GitQueryService interface { GetCommit(repoID, sha string) (*CommitInfo, error) GetContents(repoID, ref, path string) ([]ContentEntry, error) GetFile(repoID, ref, path string) ([]byte, error) + + // Patch plumbing helpers (used by PatchService). + BranchExists(repoID, branch string) (bool, error) + BranchHead(repoID, branch string) (string, error) + CreateRef(repoID, ref, sha string) error + SnapshotCommits(repoID, baseSHA, headSHA string) ([]CommitInfo, error) + SnapshotFiles(repoID, baseSHA, headSHA string) ([]PatchFileSnapshot, error) + CheckMergeable(repoID, targetSHA, sourceSHA string) (bool, error) + PerformMerge(repoID, targetBranch, targetSHA, sourceSHA, message, authorName, authorEmail string) (string, error) +} + +// PatchFileSnapshot is a single changed file in a patch snapshot. +type PatchFileSnapshot struct { + FilePath string + Status string + Diff string } type GitQueryServiceImpl struct { @@ -213,5 +233,181 @@ func cleanPath(p string) string { return strings.TrimPrefix(p, "/") } +// BranchExists reports whether refs/heads/ exists in the repo. +func (s *GitQueryServiceImpl) BranchExists(repoID, branch string) (bool, error) { + _, err := s.runGit(repoID, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + if err != nil { + // git exits non-zero when the ref is missing; treat that as "not exists". + return false, nil + } + return true, nil +} + +// BranchHead returns the commit SHA at the tip of refs/heads/. +func (s *GitQueryServiceImpl) BranchHead(repoID, branch string) (string, error) { + out, err := s.runGit(repoID, "rev-parse", "refs/heads/"+branch) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// CreateRef creates/updates a ref to point at sha (used to pin source commits +// so they are not garbage collected). +func (s *GitQueryServiceImpl) CreateRef(repoID, ref, sha string) error { + _, err := s.runGit(repoID, "update-ref", ref, sha) + return err +} + +// SnapshotCommits returns the commits in baseSHA..headSHA. +func (s *GitQueryServiceImpl) SnapshotCommits(repoID, baseSHA, headSHA string) ([]CommitInfo, error) { + out, err := s.runGit(repoID, "log", baseSHA+".."+headSHA, + "--pretty=format:%H|%an|%ae|%ad|%s", "--date=iso-strict") + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + result := make([]CommitInfo, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + continue + } + parts := strings.Split(l, "|") + if len(parts) < 5 { + continue + } + date, _ := time.Parse(time.RFC3339, parts[3]) + result = append(result, CommitInfo{ + SHA: parts[0], + Author: parts[1], + Email: parts[2], + Date: date, + Message: parts[4], + }) + } + return result, nil +} + +// SnapshotFiles returns the changed files between baseSHA and headSHA, including +// per-file diff text. +func (s *GitQueryServiceImpl) SnapshotFiles(repoID, baseSHA, headSHA string) ([]PatchFileSnapshot, error) { + out, err := s.runGit(repoID, "diff", "--name-status", baseSHA+"..."+headSHA) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + result := make([]PatchFileSnapshot, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + continue + } + fields := strings.Fields(l) + if len(fields) < 2 { + continue + } + status := fields[0] + filePath := fields[len(fields)-1] + // Normalize status prefix (e.g. "M", "M100") -> "modified". + normalized := normalizeFileStatus(status) + diff, derr := s.runGit(repoID, "diff", "--no-color", baseSHA+"..."+headSHA, "--", filePath) + if derr != nil { + diff = "" + } + result = append(result, PatchFileSnapshot{ + FilePath: filePath, + Status: normalized, + Diff: diff, + }) + } + return result, nil +} + +func normalizeFileStatus(status string) string { + switch { + case strings.HasPrefix(status, "A"): + return "added" + case strings.HasPrefix(status, "D"): + return "deleted" + case strings.HasPrefix(status, "R"): + return "renamed" + default: + return "modified" + } +} + +// CheckMergeable reports whether targetSHA can be merged into sourceSHA cleanly. +// Uses git merge-tree --write-tree; exit 0 = clean, exit 1 = conflict. +func (s *GitQueryServiceImpl) CheckMergeable(repoID, targetSHA, sourceSHA string) (bool, error) { + _, err := s.runGit(repoID, "merge-tree", "--write-tree", targetSHA, sourceSHA) + if err != nil { + // merge-tree exits 1 on conflict; any other error is a real failure. + if isExitCode(err, 1) { + return false, nil + } + return false, err + } + return true, nil +} + +// PerformMerge merges sourceSHA into targetBranch using plumbing: +// merge-tree --write-tree -> commit-tree -> update-ref (CAS). Retries up to 3 +// times if the target branch moved concurrently. Returns the new merge commit SHA. +func (s *GitQueryServiceImpl) PerformMerge(repoID, targetBranch, targetSHA, sourceSHA, message, authorName, authorEmail string) (string, error) { + path := s.storagePath(repoID) + gitBin := git.NewCommand().Executable() + + for attempt := 0; attempt < 3; attempt++ { + // 1. Compute the merge tree. + treeOut, err := s.runGit(repoID, "merge-tree", "--write-tree", targetSHA, sourceSHA) + if err != nil { + if isExitCode(err, 1) { + return "", ErrMergeConflict + } + return "", err + } + treeSHA := strings.TrimSpace(treeOut) + + // 2. Create the merge commit. + commitCmd := exec.Command(gitBin, "-C", path, "commit-tree", treeSHA, + "-p", targetSHA, "-p", sourceSHA, "-m", message) + commitCmd.Env = append(commitCmd.Environ(), + "GIT_AUTHOR_NAME="+authorName, + "GIT_AUTHOR_EMAIL="+authorEmail, + "GIT_COMMITTER_NAME="+authorName, + "GIT_COMMITTER_EMAIL="+authorEmail, + ) + commitOut, err := commitCmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git commit-tree: %w: %s", err, string(commitOut)) + } + mergeCommit := strings.TrimSpace(string(commitOut)) + + // 3. Atomically update the ref (CAS against targetSHA). + _, err = s.runGit(repoID, "update-ref", "refs/heads/"+targetBranch, mergeCommit, targetSHA) + if err != nil { + // Branch moved concurrently; refresh targetSHA and retry. + newTarget, hErr := s.BranchHead(repoID, targetBranch) + if hErr != nil { + return "", hErr + } + targetSHA = newTarget + continue + } + return mergeCommit, nil + } + return "", fmt.Errorf("merge failed after retries: target branch moved concurrently") +} + +// isExitCode reports whether err is an *exec.ExitError with the given code. +func isExitCode(err error, code int) bool { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() == code + } + return false +} + // ensure GitQueryServiceImpl satisfies the interface (compile-time check). var _ GitQueryService = (*GitQueryServiceImpl)(nil) diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go new file mode 100644 index 0000000..656887e --- /dev/null +++ b/internal/api/services/patch.go @@ -0,0 +1,538 @@ +package services + +import ( + "errors" + "fmt" + "time" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "gorm.io/gorm" +) + +// PatchService manages patch requests: creation with snapshot, refresh, merge, +// reviewer assignment, reviews and comments. +type PatchService interface { + ListPatches(owner, name string) ([]models.PatchRequest, error) + CreatePatch(owner, name, authorID, title, body, sourceBranch, targetBranch string) (*models.PatchRequest, error) + GetPatch(owner, name string, number int) (*models.PatchRequest, error) + UpdatePatch(owner, name string, number int, title, body, state string) (*models.PatchRequest, error) + RefreshPatch(owner, name string, number int) (*models.PatchRequest, error) + MergePatch(owner, name string, number int, mergerID string) (*models.PatchRequest, error) + + ListCommits(owner, name string, number int) ([]models.PatchCommit, error) + ListFiles(owner, name string, number int) ([]models.PatchFile, error) + + AssignReviewer(owner, name string, number int, username string) error + UnassignReviewer(owner, name string, number int, username string) error + ListReviewers(owner, name string, number int) ([]models.User, error) + + SubmitReview(owner, name string, number int, authorID, state, body string) error + ListReviews(owner, name string, number int) ([]models.PatchReview, error) + + CreateComment(owner, name string, number int, authorID, body, filePath string, line *int) (*models.PatchComment, error) + ListComments(owner, name string, number int) ([]models.PatchComment, error) +} + +type PatchServiceImpl struct { + RepoService RepoService + Patches repository.PatchRepository + Users repository.UserRepository + Git GitQueryService +} + +func NewPatchService(db *gorm.DB) PatchService { + return &PatchServiceImpl{ + RepoService: NewRepoService(db), + Patches: repository.NewPatchRepository(db), + Users: repository.NewUserRepository(db), + Git: NewGitQueryService(db), + } +} + +func (s *PatchServiceImpl) resolve(owner, name string) (*models.Repository, error) { + return s.RepoService.GetRepository(owner, name) +} + +func (s *PatchServiceImpl) ListPatches(owner, name string) ([]models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + var patches []models.PatchRequest + if err := s.Patches.FindByRepo(repo.ID.String(), &patches); err != nil { + return nil, err + } + return patches, nil +} + +func (s *PatchServiceImpl) CreatePatch(owner, name, authorID, title, body, sourceBranch, targetBranch string) (*models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + + // Validate branches exist. + srcExists, err := s.Git.BranchExists(repo.ID.String(), sourceBranch) + if err != nil { + return nil, err + } + if !srcExists { + return nil, fmt.Errorf("source branch %q not found", sourceBranch) + } + tgtExists, err := s.Git.BranchExists(repo.ID.String(), targetBranch) + if err != nil { + return nil, err + } + if !tgtExists { + return nil, fmt.Errorf("target branch %q not found", targetBranch) + } + + baseSHA, err := s.Git.BranchHead(repo.ID.String(), targetBranch) + if err != nil { + return nil, err + } + headSHA, err := s.Git.BranchHead(repo.ID.String(), sourceBranch) + if err != nil { + return nil, err + } + + // Snapshot commits & files. + commits, err := s.Git.SnapshotCommits(repo.ID.String(), baseSHA, headSHA) + if err != nil { + return nil, err + } + files, err := s.Git.SnapshotFiles(repo.ID.String(), baseSHA, headSHA) + if err != nil { + return nil, err + } + + // Atomic number allocation + create in one transaction. + var created models.PatchRequest + err = s.Patches.WithTx(func(tx repository.PatchRepository) error { + number, err := tx.NextNumber(repo.ID.String()) + if err != nil { + return err + } + patch := models.PatchRequest{ + RepoID: repo.ID.String(), + Number: number, + Title: title, + Body: body, + SourceBranch: sourceBranch, + TargetBranch: targetBranch, + AuthorID: authorID, + State: models.PatchStateOpen, + BaseSHA: baseSHA, + HeadSHA: headSHA, + } + if err := tx.Create(&patch); err != nil { + return err + } + // Persist snapshot. + patchCommits := toPatchCommits(patch.ID.String(), commits) + if err := tx.ReplaceCommits(patch.ID.String(), patchCommits); err != nil { + return err + } + patchFiles := toPatchFiles(patch.ID.String(), files) + if err := tx.ReplaceFiles(patch.ID.String(), patchFiles); err != nil { + return err + } + // Pin source commits so they are not GC'd. + if err := s.Git.CreateRef(repo.ID.String(), fmt.Sprintf("refs/patches/%d/head", number), headSHA); err != nil { + return err + } + created = patch + return nil + }) + if err != nil { + return nil, err + } + + // Check mergeability (best-effort; cache result). + mergeable, mErr := s.Git.CheckMergeable(repo.ID.String(), baseSHA, headSHA) + if mErr == nil { + created.IsMergeable = &mergeable + if err := s.Patches.Update(&created); err != nil { + return nil, err + } + } + + // Reload with associations. + reloaded, err := s.Patches.FindByID(created.ID.String()) + if err != nil { + return nil, err + } + return &reloaded, nil +} + +func (s *PatchServiceImpl) GetPatch(owner, name string, number int) (*models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + patch, err := s.Patches.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("patch not found") + } + return &patch, nil +} + +func (s *PatchServiceImpl) UpdatePatch(owner, name string, number int, title, body, state string) (*models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + patch, err := s.Patches.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("patch not found") + } + if title != "" { + patch.Title = title + } + if body != "" { + patch.Body = body + } + if state != "" && models.ValidPatchState(state) { + patch.State = state + if state == models.PatchStateClosed && patch.ClosedAt == nil { + now := time.Now() + patch.ClosedAt = &now + } + } + if err := s.Patches.Update(&patch); err != nil { + return nil, err + } + reloaded, err := s.Patches.FindByID(patch.ID.String()) + if err != nil { + return nil, err + } + return &reloaded, nil +} + +func (s *PatchServiceImpl) RefreshPatch(owner, name string, number int) (*models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + patch, err := s.Patches.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("patch not found") + } + if patch.State != models.PatchStateOpen { + return nil, errors.New("only open patches can be refreshed") + } + + newBase, err := s.Git.BranchHead(repo.ID.String(), patch.TargetBranch) + if err != nil { + return nil, err + } + newHead, err := s.Git.BranchHead(repo.ID.String(), patch.SourceBranch) + if err != nil { + return nil, err + } + if newBase == patch.BaseSHA && newHead == patch.HeadSHA { + return &patch, nil // no-op + } + + commits, err := s.Git.SnapshotCommits(repo.ID.String(), newBase, newHead) + if err != nil { + return nil, err + } + files, err := s.Git.SnapshotFiles(repo.ID.String(), newBase, newHead) + if err != nil { + return nil, err + } + + patch.BaseSHA = newBase + patch.HeadSHA = newHead + if err := s.Patches.ReplaceCommits(patch.ID.String(), toPatchCommits(patch.ID.String(), commits)); err != nil { + return nil, err + } + if err := s.Patches.ReplaceFiles(patch.ID.String(), toPatchFiles(patch.ID.String(), files)); err != nil { + return nil, err + } + if err := s.Git.CreateRef(repo.ID.String(), fmt.Sprintf("refs/patches/%d/head", patch.Number), newHead); err != nil { + return nil, err + } + mergeable, mErr := s.Git.CheckMergeable(repo.ID.String(), newBase, newHead) + if mErr == nil { + patch.IsMergeable = &mergeable + } + if err := s.Patches.Update(&patch); err != nil { + return nil, err + } + reloaded, err := s.Patches.FindByID(patch.ID.String()) + if err != nil { + return nil, err + } + return &reloaded, nil +} + +func (s *PatchServiceImpl) MergePatch(owner, name string, number int, mergerID string) (*models.PatchRequest, error) { + repo, err := s.resolve(owner, name) + if err != nil { + return nil, err + } + patch, err := s.Patches.FindByNumber(repo.ID.String(), number) + if err != nil { + return nil, errors.New("patch not found") + } + if patch.State != models.PatchStateOpen { + return nil, errors.New("patch is not open") + } + + // Guard: at least one assigned reviewer must have approved. + reviewers, err := s.listReviewersRaw(patch.ID.String()) + if err != nil { + return nil, err + } + if len(reviewers) == 0 { + return nil, errors.New("at least one reviewer must be assigned") + } + approved, err := s.allReviewersApproved(patch.ID.String()) + if err != nil { + return nil, err + } + if !approved { + return nil, errors.New("all assigned reviewers must approve before merge") + } + + // Use the CURRENT target branch tip (not the stale snapshot base). + targetSHA, err := s.Git.BranchHead(repo.ID.String(), patch.TargetBranch) + if err != nil { + return nil, err + } + + merger, err := s.Users.FindByID(mergerID) + if err != nil { + return nil, err + } + + mergeCommit, err := s.Git.PerformMerge( + repo.ID.String(), + patch.TargetBranch, + targetSHA, + patch.HeadSHA, + fmt.Sprintf("Merge patch #%d: %s", patch.Number, patch.Title), + merger.Username, + merger.Email, + ) + if errors.Is(err, ErrMergeConflict) { + return nil, errors.New("merge conflict: please refresh the patch and resolve conflicts") + } + if err != nil { + return nil, err + } + + // Git succeeded; now update DB. + now := time.Now() + patch.State = models.PatchStateMerged + patch.MergeCommitSHA = &mergeCommit + patch.MergedAt = &now + if err := s.Patches.Update(&patch); err != nil { + return nil, err + } + reloaded, err := s.Patches.FindByID(patch.ID.String()) + if err != nil { + return nil, err + } + return &reloaded, nil +} + +func (s *PatchServiceImpl) ListCommits(owner, name string, number int) ([]models.PatchCommit, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + var commits []models.PatchCommit + if err := s.Patches.FindCommits(patch.ID.String(), &commits); err != nil { + return nil, err + } + return commits, nil +} + +func (s *PatchServiceImpl) ListFiles(owner, name string, number int) ([]models.PatchFile, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + var files []models.PatchFile + if err := s.Patches.FindFiles(patch.ID.String(), &files); err != nil { + return nil, err + } + return files, nil +} + +func (s *PatchServiceImpl) AssignReviewer(owner, name string, number int, username string) error { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return errors.New("user not found") + } + return s.Patches.AddReviewer(patch.ID.String(), user.ID.String()) +} + +func (s *PatchServiceImpl) UnassignReviewer(owner, name string, number int, username string) error { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + return errors.New("user not found") + } + return s.Patches.RemoveReviewer(patch.ID.String(), user.ID.String()) +} + +func (s *PatchServiceImpl) ListReviewers(owner, name string, number int) ([]models.User, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + var reviewers []models.User + if err := s.Patches.FindReviewers(patch.ID.String(), &reviewers); err != nil { + return nil, err + } + return reviewers, nil +} + +func (s *PatchServiceImpl) listReviewersRaw(patchID string) ([]models.User, error) { + var reviewers []models.User + if err := s.Patches.FindReviewers(patchID, &reviewers); err != nil { + return nil, err + } + return reviewers, nil +} + +func (s *PatchServiceImpl) allReviewersApproved(patchID string) (bool, error) { + var reviews []models.PatchReview + if err := s.Patches.FindReviews(patchID, &reviews); err != nil { + return false, err + } + approvedSet := make(map[string]bool) + for _, r := range reviews { + if r.State == models.PatchReviewApproved { + approvedSet[r.AuthorID] = true + } + } + reviewers, err := s.listReviewersRaw(patchID) + if err != nil { + return false, err + } + if len(reviewers) == 0 { + return false, nil + } + for _, rv := range reviewers { + if !approvedSet[rv.ID.String()] { + return false, nil + } + } + return true, nil +} + +func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID, state, body string) error { + if !models.ValidPatchReviewState(state) { + return errors.New("invalid review state") + } + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return err + } + isRev, err := s.Patches.IsReviewer(patch.ID.String(), authorID) + if err != nil { + return err + } + if !isRev { + return errors.New("only assigned reviewers can submit a review") + } + review := &models.PatchReview{ + PatchID: patch.ID.String(), + AuthorID: authorID, + State: state, + Body: body, + } + return s.Patches.UpsertReview(review) +} + +func (s *PatchServiceImpl) ListReviews(owner, name string, number int) ([]models.PatchReview, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + var reviews []models.PatchReview + if err := s.Patches.FindReviews(patch.ID.String(), &reviews); err != nil { + return nil, err + } + return reviews, nil +} + +func (s *PatchServiceImpl) CreateComment(owner, name string, number int, authorID, body, filePath string, line *int) (*models.PatchComment, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + comment := &models.PatchComment{ + PatchID: patch.ID.String(), + AuthorID: authorID, + Body: body, + } + if filePath != "" { + comment.FilePath = &filePath + comment.Line = line + } + if err := s.Patches.CreateComment(comment); err != nil { + return nil, err + } + reloaded, err := s.Patches.FindCommentByID(comment.ID.String()) + if err != nil { + return nil, err + } + return &reloaded, nil +} + +func (s *PatchServiceImpl) ListComments(owner, name string, number int) ([]models.PatchComment, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + var comments []models.PatchComment + if err := s.Patches.FindComments(patch.ID.String(), &comments); err != nil { + return nil, err + } + return comments, nil +} + +// --- converters --- + +func toPatchCommits(patchID string, commits []CommitInfo) []models.PatchCommit { + result := make([]models.PatchCommit, 0, len(commits)) + for _, c := range commits { + result = append(result, models.PatchCommit{ + PatchID: patchID, + SHA: c.SHA, + Message: c.Message, + AuthorName: c.Author, + AuthorEmail: c.Email, + AuthorDate: c.Date, + }) + } + return result +} + +func toPatchFiles(patchID string, files []PatchFileSnapshot) []models.PatchFile { + result := make([]models.PatchFile, 0, len(files)) + for _, f := range files { + result = append(result, models.PatchFile{ + PatchID: patchID, + FilePath: f.FilePath, + Status: f.Status, + Diff: f.Diff, + }) + } + return result +} diff --git a/internal/models/patch.go b/internal/models/patch.go new file mode 100644 index 0000000..e2282a3 --- /dev/null +++ b/internal/models/patch.go @@ -0,0 +1,148 @@ +package models + +import ( + "time" +) + +// Patch request state constants. +const ( + PatchStateOpen = "open" + PatchStateMerged = "merged" + PatchStateClosed = "closed" +) + +// ValidPatchState reports whether state is a recognized patch state. +func ValidPatchState(state string) bool { + return state == PatchStateOpen || state == PatchStateMerged || state == PatchStateClosed +} + +// PatchFile status constants. +const ( + PatchFileAdded = "added" + PatchFileModified = "modified" + PatchFileDeleted = "deleted" + PatchFileRenamed = "renamed" +) + +// ValidPatchFileStatus reports whether status is a recognized file status. +func ValidPatchFileStatus(status string) bool { + switch status { + case PatchFileAdded, PatchFileModified, PatchFileDeleted, PatchFileRenamed: + return true + default: + return false + } +} + +// PatchReview state constants. +const ( + PatchReviewApproved = "approved" + PatchReviewChangesRequested = "changes_requested" + PatchReviewCommented = "commented" +) + +// ValidPatchReviewState reports whether state is a recognized review state. +func ValidPatchReviewState(state string) bool { + switch state { + case PatchReviewApproved, PatchReviewChangesRequested, PatchReviewCommented: + return true + default: + return false + } +} + +// PatchRequest is a request to merge source_branch into target_branch. +type PatchRequest struct { + Base + + RepoID string `json:"repo_id" gorm:"type:uuid;not null;uniqueIndex:uniq_repo_patch"` + Number int `json:"number" gorm:"not null;uniqueIndex:uniq_repo_patch"` + Title string `json:"title" gorm:"size:255;not null"` + Body string `json:"body" gorm:"type:text"` + SourceBranch string `json:"source_branch" gorm:"size:255;not null"` + TargetBranch string `json:"target_branch" gorm:"size:255;not null"` + AuthorID string `json:"author_id" gorm:"type:uuid;not null"` + State string `json:"state" gorm:"size:10;not null;default:'open'"` + BaseSHA string `json:"base_sha" gorm:"size:40"` + HeadSHA string `json:"head_sha" gorm:"size:40"` + MergeCommitSHA *string `json:"merge_commit_sha" gorm:"size:40"` + IsMergeable *bool `json:"is_mergeable"` + MergedAt *time.Time `json:"merged_at"` + ClosedAt *time.Time `json:"closed_at"` + + Author *User `json:"author" gorm:"foreignKey:AuthorID;references:ID"` + Commits []PatchCommit `json:"commits" gorm:"foreignKey:PatchID;references:ID"` + Files []PatchFile `json:"files" gorm:"foreignKey:PatchID;references:ID"` + Reviewers []PatchReviewer `json:"reviewers" gorm:"foreignKey:PatchID;references:ID"` + Reviews []PatchReview `json:"reviews" gorm:"foreignKey:PatchID;references:ID"` + Comments []PatchComment `json:"comments" gorm:"foreignKey:PatchID;references:ID"` +} + +func (PatchRequest) TableName() string { return "patch_requests" } + +// PatchCommit is a snapshot of a single commit in the patch. +type PatchCommit struct { + Base + + PatchID string `json:"patch_id" gorm:"type:uuid;not null"` + SHA string `json:"sha" gorm:"size:40;not null"` + Message string `json:"message" gorm:"type:text"` + AuthorName string `json:"author_name" gorm:"size:255"` + AuthorEmail string `json:"author_email" gorm:"size:255"` + AuthorDate time.Time `json:"author_date"` +} + +func (PatchCommit) TableName() string { return "patch_commits" } + +// PatchFile is a snapshot of a single changed file in the patch. +type PatchFile struct { + Base + + PatchID string `json:"patch_id" gorm:"type:uuid;not null"` + FilePath string `json:"file_path" gorm:"type:text;not null"` + Status string `json:"status" gorm:"size:20;not null"` + Diff string `json:"diff" gorm:"type:text"` +} + +func (PatchFile) TableName() string { return "patch_files" } + +// PatchReviewer is an explicit assignment of a reviewer to a patch. +type PatchReviewer struct { + Base + + PatchID string `json:"patch_id" gorm:"type:uuid;not null;uniqueIndex:uniq_patch_reviewer"` + UserID string `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:uniq_patch_reviewer"` + + User *User `json:"user" gorm:"foreignKey:UserID;references:ID"` +} + +func (PatchReviewer) TableName() string { return "patch_reviewers" } + +// PatchReview is a review submission by an assigned reviewer. +type PatchReview struct { + Base + + PatchID string `json:"patch_id" gorm:"type:uuid;not null"` + AuthorID string `json:"author_id" gorm:"type:uuid;not null"` + State string `json:"state" gorm:"size:20;not null"` + Body string `json:"body" gorm:"type:text"` + + Author *User `json:"author" gorm:"foreignKey:AuthorID;references:ID"` +} + +func (PatchReview) TableName() string { return "patch_reviews" } + +// PatchComment is a comment on a patch (can be inline on a file/line). +type PatchComment struct { + Base + + PatchID string `json:"patch_id" gorm:"type:uuid;not null"` + AuthorID string `json:"author_id" gorm:"type:uuid;not null"` + Body string `json:"body" gorm:"type:text;not null"` + FilePath *string `json:"file_path" gorm:"type:text"` + Line *int `json:"line"` + + Author *User `json:"author" gorm:"foreignKey:AuthorID;references:ID"` +} + +func (PatchComment) TableName() string { return "patch_comments" } diff --git a/internal/repository/patch.go b/internal/repository/patch.go new file mode 100644 index 0000000..ca185f7 --- /dev/null +++ b/internal/repository/patch.go @@ -0,0 +1,242 @@ +package repository + +import ( + "gitxyz/internal/models" + + "gorm.io/gorm" +) + +// PatchRepository provides data access for patch requests and their relations. +type PatchRepository interface { + // Patch requests + Create(patch *models.PatchRequest) error + FindByRepo(repoID string, dest *[]models.PatchRequest) error + FindByNumber(repoID string, number int) (models.PatchRequest, error) + FindByID(id string) (models.PatchRequest, error) + Update(patch *models.PatchRequest) error + Delete(patch *models.PatchRequest) error + NextNumber(repoID string) (int, error) + + // Snapshot commits + ReplaceCommits(patchID string, commits []models.PatchCommit) error + FindCommits(patchID string, dest *[]models.PatchCommit) error + + // Snapshot files + ReplaceFiles(patchID string, files []models.PatchFile) error + FindFiles(patchID string, dest *[]models.PatchFile) error + + // Reviewers + AddReviewer(patchID, userID string) error + RemoveReviewer(patchID, userID string) error + FindReviewers(patchID string, dest *[]models.User) error + IsReviewer(patchID, userID string) (bool, error) + + // Reviews + UpsertReview(review *models.PatchReview) error + FindReviews(patchID string, dest *[]models.PatchReview) error + + // Comments + CreateComment(comment *models.PatchComment) error + FindComments(patchID string, dest *[]models.PatchComment) error + FindCommentByID(id string) (models.PatchComment, error) + + // Transaction + WithTx(fn func(tx PatchRepository) error) error +} + +type PatchRepositoryImpl struct { + db *gorm.DB +} + +func NewPatchRepository(db *gorm.DB) *PatchRepositoryImpl { + return &PatchRepositoryImpl{db: db} +} + +func (r *PatchRepositoryImpl) Create(patch *models.PatchRequest) error { + return r.db.Create(patch).Error +} + +func (r *PatchRepositoryImpl) FindByRepo(repoID string, dest *[]models.PatchRequest) error { + return r.db. + Preload("Author"). + Preload("Reviewers.User"). + Where("repo_id = ?", repoID). + Order("number ASC"). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) FindByNumber(repoID string, number int) (models.PatchRequest, error) { + var patch models.PatchRequest + err := r.db. + Preload("Author"). + Preload("Reviewers.User"). + Preload("Reviews.Author"). + Preload("Commits"). + Preload("Files"). + Where("repo_id = ? AND number = ?", repoID, number). + First(&patch).Error + return patch, err +} + +func (r *PatchRepositoryImpl) FindByID(id string) (models.PatchRequest, error) { + var patch models.PatchRequest + err := r.db. + Preload("Author"). + Preload("Reviewers.User"). + Preload("Reviews.Author"). + Preload("Commits"). + Preload("Files"). + Where("id = ?", id). + First(&patch).Error + return patch, err +} + +func (r *PatchRepositoryImpl) Update(patch *models.PatchRequest) error { + return r.db.Model(patch).Updates(map[string]interface{}{ + "title": patch.Title, + "body": patch.Body, + "state": patch.State, + "merge_commit_sha": patch.MergeCommitSHA, + "is_mergeable": patch.IsMergeable, + "base_sha": patch.BaseSHA, + "head_sha": patch.HeadSHA, + "merged_at": patch.MergedAt, + "closed_at": patch.ClosedAt, + }).Error +} + +func (r *PatchRepositoryImpl) Delete(patch *models.PatchRequest) error { + return r.db.Delete(patch).Error +} + +// NextNumber atomically increments and returns the repository's item counter. +// Uses a single UPDATE ... RETURNING to avoid the MAX+1 race condition. +func (r *PatchRepositoryImpl) NextNumber(repoID string) (int, error) { + var next int + err := r.db. + Model(&models.Repository{}). + Where("id = ?", repoID). + UpdateColumn("last_item_number", gorm.Expr("last_item_number + 1")). + Select("last_item_number"). + Scan(&next).Error + if err != nil { + return 0, err + } + return next, nil +} + +func (r *PatchRepositoryImpl) ReplaceCommits(patchID string, commits []models.PatchCommit) error { + if err := r.db.Where("patch_id = ?", patchID).Delete(&models.PatchCommit{}).Error; err != nil { + return err + } + if len(commits) == 0 { + return nil + } + return r.db.Create(&commits).Error +} + +func (r *PatchRepositoryImpl) FindCommits(patchID string, dest *[]models.PatchCommit) error { + return r.db. + Where("patch_id = ?", patchID). + Order("author_date ASC"). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) ReplaceFiles(patchID string, files []models.PatchFile) error { + if err := r.db.Where("patch_id = ?", patchID).Delete(&models.PatchFile{}).Error; err != nil { + return err + } + if len(files) == 0 { + return nil + } + return r.db.Create(&files).Error +} + +func (r *PatchRepositoryImpl) FindFiles(patchID string, dest *[]models.PatchFile) error { + return r.db. + Where("patch_id = ?", patchID). + Order("file_path ASC"). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) AddReviewer(patchID, userID string) error { + reviewer := models.PatchReviewer{PatchID: patchID, UserID: userID} + return r.db.Create(&reviewer).Error +} + +func (r *PatchRepositoryImpl) RemoveReviewer(patchID, userID string) error { + return r.db. + Where("patch_id = ? AND user_id = ?", patchID, userID). + Delete(&models.PatchReviewer{}).Error +} + +func (r *PatchRepositoryImpl) FindReviewers(patchID string, dest *[]models.User) error { + return r.db. + Joins("JOIN patch_reviewers ON patch_reviewers.user_id = users.id"). + Where("patch_reviewers.patch_id = ?", patchID). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) IsReviewer(patchID, userID string) (bool, error) { + var count int64 + err := r.db. + Model(&models.PatchReviewer{}). + Where("patch_id = ? AND user_id = ?", patchID, userID). + Count(&count).Error + return count > 0, err +} + +func (r *PatchRepositoryImpl) UpsertReview(review *models.PatchReview) error { + var existing models.PatchReview + err := r.db. + Where("patch_id = ? AND author_id = ?", review.PatchID, review.AuthorID). + First(&existing).Error + if err == gorm.ErrRecordNotFound { + return r.db.Create(review).Error + } + if err != nil { + return err + } + existing.State = review.State + existing.Body = review.Body + return r.db.Model(&existing).Updates(map[string]interface{}{ + "state": review.State, + "body": review.Body, + }).Error +} + +func (r *PatchRepositoryImpl) FindReviews(patchID string, dest *[]models.PatchReview) error { + return r.db. + Preload("Author"). + Where("patch_id = ?", patchID). + Order("created_at ASC"). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) CreateComment(comment *models.PatchComment) error { + return r.db.Create(comment).Error +} + +func (r *PatchRepositoryImpl) FindComments(patchID string, dest *[]models.PatchComment) error { + return r.db. + Preload("Author"). + Where("patch_id = ?", patchID). + Order("created_at ASC"). + Find(dest).Error +} + +func (r *PatchRepositoryImpl) FindCommentByID(id string) (models.PatchComment, error) { + var comment models.PatchComment + err := r.db. + Preload("Author"). + Where("id = ?", id). + First(&comment).Error + return comment, err +} + +// WithTx runs fn inside a transaction, passing a repository bound to the tx. +func (r *PatchRepositoryImpl) WithTx(fn func(tx PatchRepository) error) error { + return r.db.Transaction(func(gdb *gorm.DB) error { + return fn(&PatchRepositoryImpl{db: gdb}) + }) +} From 6cc4be19dc26052ac01fc236d230a8f7758cf862 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:15:40 +0700 Subject: [PATCH 46/49] feat(patch): enhance response handling with standardized envelope structure --- internal/api/controllers/patch.go | 73 ++++++++++++++------------- internal/api/dto/response/envelope.go | 39 ++++++++++++++ internal/api/services/patch.go | 43 +++++++++------- 3 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 internal/api/dto/response/envelope.go diff --git a/internal/api/controllers/patch.go b/internal/api/controllers/patch.go index fecb73a..0a8c558 100644 --- a/internal/api/controllers/patch.go +++ b/internal/api/controllers/patch.go @@ -61,10 +61,10 @@ func (c *PatchControllerImpl) List(ctx *gin.Context) { owner, repo := c.ownerRepo(ctx) patches, err := c.service.ListPatches(owner, repo) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchResponseSlice(patches)) + response.WriteOK(ctx, "patches listed", response.ToPatchResponseSlice(patches)) } func (c *PatchControllerImpl) Create(ctx *gin.Context) { @@ -77,10 +77,10 @@ func (c *PatchControllerImpl) Create(ctx *gin.Context) { authorID := ctx.GetString("user_id") patch, err := c.service.CreatePatch(owner, repo, authorID, req.Title, req.Body, req.SourceBranch, req.TargetBranch) if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.JSON(http.StatusCreated, response.ToPatchResponse(patch)) + response.WriteCreated(ctx, "patch created", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) Get(ctx *gin.Context) { @@ -91,10 +91,10 @@ func (c *PatchControllerImpl) Get(ctx *gin.Context) { } patch, err := c.service.GetPatch(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) + response.WriteOK(ctx, "patch found", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) Update(ctx *gin.Context) { @@ -120,10 +120,10 @@ func (c *PatchControllerImpl) Update(ctx *gin.Context) { } patch, err := c.service.UpdatePatch(owner, repo, n, title, body, state) if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) + response.WriteOK(ctx, "patch updated", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) Refresh(ctx *gin.Context) { @@ -134,10 +134,10 @@ func (c *PatchControllerImpl) Refresh(ctx *gin.Context) { } patch, err := c.service.RefreshPatch(owner, repo, n) if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) + response.WriteOK(ctx, "patch refreshed", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) Merge(ctx *gin.Context) { @@ -149,10 +149,10 @@ func (c *PatchControllerImpl) Merge(ctx *gin.Context) { mergerID := ctx.GetString("user_id") patch, err := c.service.MergePatch(owner, repo, n, mergerID) if err != nil { - ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusConflict, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchResponse(patch)) + response.WriteOK(ctx, "patch merged", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) ListCommits(ctx *gin.Context) { @@ -163,10 +163,10 @@ func (c *PatchControllerImpl) ListCommits(ctx *gin.Context) { } commits, err := c.service.ListCommits(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchCommitResponseSlice(commits)) + response.WriteOK(ctx, "commits listed", response.ToPatchCommitResponseSlice(commits)) } func (c *PatchControllerImpl) ListFiles(ctx *gin.Context) { @@ -177,10 +177,10 @@ func (c *PatchControllerImpl) ListFiles(ctx *gin.Context) { } files, err := c.service.ListFiles(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchFileResponseSlice(files)) + response.WriteOK(ctx, "files listed", response.ToPatchFileResponseSlice(files)) } func (c *PatchControllerImpl) AssignReviewer(ctx *gin.Context) { @@ -191,14 +191,15 @@ func (c *PatchControllerImpl) AssignReviewer(ctx *gin.Context) { } var req dto.AssignPatchReviewerRequest if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - if err := c.service.AssignReviewer(owner, repo, n, req.Username); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + patch, err := c.service.AssignReviewer(owner, repo, n, req.Username) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.Status(http.StatusNoContent) + response.WriteOK(ctx, "reviewer assigned", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) UnassignReviewer(ctx *gin.Context) { @@ -208,11 +209,12 @@ func (c *PatchControllerImpl) UnassignReviewer(ctx *gin.Context) { return } username := ctx.Param("username") - if err := c.service.UnassignReviewer(owner, repo, n, username); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + patch, err := c.service.UnassignReviewer(owner, repo, n, username) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.Status(http.StatusNoContent) + response.WriteOK(ctx, "reviewer unassigned", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) ListReviewers(ctx *gin.Context) { @@ -223,10 +225,10 @@ func (c *PatchControllerImpl) ListReviewers(ctx *gin.Context) { } reviewers, err := c.service.ListReviewers(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToUserResponseSlice(reviewers)) + response.WriteOK(ctx, "reviewers listed", response.ToUserResponseSlice(reviewers)) } func (c *PatchControllerImpl) SubmitReview(ctx *gin.Context) { @@ -237,15 +239,16 @@ func (c *PatchControllerImpl) SubmitReview(ctx *gin.Context) { } var req dto.SubmitPatchReviewRequest if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } authorID := ctx.GetString("user_id") - if err := c.service.SubmitReview(owner, repo, n, authorID, req.State, req.Body); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + patch, err := c.service.SubmitReview(owner, repo, n, authorID, req.State, req.Body) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.Status(http.StatusNoContent) + response.WriteOK(ctx, "review submitted", response.ToPatchResponse(patch)) } func (c *PatchControllerImpl) ListReviews(ctx *gin.Context) { @@ -256,10 +259,10 @@ func (c *PatchControllerImpl) ListReviews(ctx *gin.Context) { } reviews, err := c.service.ListReviews(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchReviewResponseSlice(reviews)) + response.WriteOK(ctx, "reviews listed", response.ToPatchReviewResponseSlice(reviews)) } func (c *PatchControllerImpl) CreateComment(ctx *gin.Context) { @@ -276,10 +279,10 @@ func (c *PatchControllerImpl) CreateComment(ctx *gin.Context) { authorID := ctx.GetString("user_id") comment, err := c.service.CreateComment(owner, repo, n, authorID, req.Body, req.FilePath, req.Line) if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusBadRequest, err.Error()) return } - ctx.JSON(http.StatusCreated, response.ToPatchCommentResponse(comment)) + response.WriteCreated(ctx, "comment created", response.ToPatchCommentResponse(comment)) } func (c *PatchControllerImpl) ListComments(ctx *gin.Context) { @@ -290,8 +293,8 @@ func (c *PatchControllerImpl) ListComments(ctx *gin.Context) { } comments, err := c.service.ListComments(owner, repo, n) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + response.WriteError(ctx, http.StatusNotFound, err.Error()) return } - ctx.JSON(http.StatusOK, response.ToPatchCommentResponseSlice(comments)) + response.WriteOK(ctx, "comments listed", response.ToPatchCommentResponseSlice(comments)) } diff --git a/internal/api/dto/response/envelope.go b/internal/api/dto/response/envelope.go new file mode 100644 index 0000000..d476c59 --- /dev/null +++ b/internal/api/dto/response/envelope.go @@ -0,0 +1,39 @@ +package dto + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Envelope adalah standar response body untuk seluruh endpoint. +type Envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data any `json:"data"` +} + +// OK mengembalikan envelope sukses dengan data dan pesan tertentu. +func OK(message string, data any) Envelope { + return Envelope{Status: "ok", Message: message, Data: data} +} + +// ErrorEnvelope mengembalikan envelope gagal dengan pesan tertentu. +func ErrorEnvelope(message string) Envelope { + return Envelope{Status: "error", Message: message, Data: nil} +} + +// WriteOK menulis response JSON sukses (200) dengan envelope standar. +func WriteOK(ctx *gin.Context, message string, data any) { + ctx.JSON(http.StatusOK, OK(message, data)) +} + +// WriteCreated menulis response JSON sukses (201) dengan envelope standar. +func WriteCreated(ctx *gin.Context, message string, data any) { + ctx.JSON(http.StatusCreated, OK(message, data)) +} + +// WriteError menulis response JSON gagal dengan status code tertentu. +func WriteError(ctx *gin.Context, status int, message string) { + ctx.JSON(status, ErrorEnvelope(message)) +} diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go index 656887e..a2d46b6 100644 --- a/internal/api/services/patch.go +++ b/internal/api/services/patch.go @@ -24,11 +24,11 @@ type PatchService interface { ListCommits(owner, name string, number int) ([]models.PatchCommit, error) ListFiles(owner, name string, number int) ([]models.PatchFile, error) - AssignReviewer(owner, name string, number int, username string) error - UnassignReviewer(owner, name string, number int, username string) error + AssignReviewer(owner, name string, number int, username string) (*models.PatchRequest, error) + UnassignReviewer(owner, name string, number int, username string) (*models.PatchRequest, error) ListReviewers(owner, name string, number int) ([]models.User, error) - SubmitReview(owner, name string, number int, authorID, state, body string) error + SubmitReview(owner, name string, number int, authorID, state, body string) (*models.PatchRequest, error) ListReviews(owner, name string, number int) ([]models.PatchReview, error) CreateComment(owner, name string, number int, authorID, body, filePath string, line *int) (*models.PatchComment, error) @@ -365,28 +365,34 @@ func (s *PatchServiceImpl) ListFiles(owner, name string, number int) ([]models.P return files, nil } -func (s *PatchServiceImpl) AssignReviewer(owner, name string, number int, username string) error { +func (s *PatchServiceImpl) AssignReviewer(owner, name string, number int, username string) (*models.PatchRequest, error) { patch, err := s.GetPatch(owner, name, number) if err != nil { - return err + return nil, err } user, err := s.Users.FindByUsername(username) if err != nil { - return errors.New("user not found") + return nil, errors.New("user not found") + } + if err := s.Patches.AddReviewer(patch.ID.String(), user.ID.String()); err != nil { + return nil, err } - return s.Patches.AddReviewer(patch.ID.String(), user.ID.String()) + return s.GetPatch(owner, name, number) } -func (s *PatchServiceImpl) UnassignReviewer(owner, name string, number int, username string) error { +func (s *PatchServiceImpl) UnassignReviewer(owner, name string, number int, username string) (*models.PatchRequest, error) { patch, err := s.GetPatch(owner, name, number) if err != nil { - return err + return nil, err } user, err := s.Users.FindByUsername(username) if err != nil { - return errors.New("user not found") + return nil, errors.New("user not found") + } + if err := s.Patches.RemoveReviewer(patch.ID.String(), user.ID.String()); err != nil { + return nil, err } - return s.Patches.RemoveReviewer(patch.ID.String(), user.ID.String()) + return s.GetPatch(owner, name, number) } func (s *PatchServiceImpl) ListReviewers(owner, name string, number int) ([]models.User, error) { @@ -435,20 +441,20 @@ func (s *PatchServiceImpl) allReviewersApproved(patchID string) (bool, error) { return true, nil } -func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID, state, body string) error { +func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID, state, body string) (*models.PatchRequest, error) { if !models.ValidPatchReviewState(state) { - return errors.New("invalid review state") + return nil, errors.New("invalid review state") } patch, err := s.GetPatch(owner, name, number) if err != nil { - return err + return nil, err } isRev, err := s.Patches.IsReviewer(patch.ID.String(), authorID) if err != nil { - return err + return nil, err } if !isRev { - return errors.New("only assigned reviewers can submit a review") + return nil, errors.New("only assigned reviewers can submit a review") } review := &models.PatchReview{ PatchID: patch.ID.String(), @@ -456,7 +462,10 @@ func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID State: state, Body: body, } - return s.Patches.UpsertReview(review) + if err := s.Patches.UpsertReview(review); err != nil { + return nil, err + } + return s.GetPatch(owner, name, number) } func (s *PatchServiceImpl) ListReviews(owner, name string, number int) ([]models.PatchReview, error) { From 088a7c467c8a729df5ba6624a1b18dadbefed4cd Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:47:06 +0700 Subject: [PATCH 47/49] feat(patch): enhance error handling for author and merger validation --- internal/api/middlewares/auth.go | 7 +++++++ internal/api/services/patch.go | 11 +++++++++-- internal/repository/patch.go | 29 +++++++++++++++++------------ internal/repository/user.go | 4 ++++ 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/internal/api/middlewares/auth.go b/internal/api/middlewares/auth.go index c32527a..a09e954 100644 --- a/internal/api/middlewares/auth.go +++ b/internal/api/middlewares/auth.go @@ -52,6 +52,13 @@ func AuthRequired() gin.HandlerFunc { } } + // Without a resolvable user ID we cannot attribute any mutation + // (FK constraints on author_id would fail). Reject the request. + if userID == "" { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unable to resolve user from token"}) + return + } + ctx.Set("user_id", userID) ctx.Set("username", claims.Username) ctx.Set("email", claims.Email) diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go index a2d46b6..1663609 100644 --- a/internal/api/services/patch.go +++ b/internal/api/services/patch.go @@ -8,6 +8,7 @@ import ( "gitxyz/internal/models" "gitxyz/internal/repository" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -68,6 +69,12 @@ func (s *PatchServiceImpl) ListPatches(owner, name string) ([]models.PatchReques } func (s *PatchServiceImpl) CreatePatch(owner, name, authorID, title, body, sourceBranch, targetBranch string) (*models.PatchRequest, error) { + // Validate the author exists before any mutation; otherwise the FK on + // patch_requests.author_id would fail with a cryptic constraint error. + if _, err := s.Users.FindByID(authorID); err != nil { + return nil, errors.New("author not found") + } + repo, err := s.resolve(owner, name) if err != nil { return nil, err @@ -306,8 +313,8 @@ func (s *PatchServiceImpl) MergePatch(owner, name string, number int, mergerID s } merger, err := s.Users.FindByID(mergerID) - if err != nil { - return nil, err + if err != nil || merger.ID == uuid.Nil { + return nil, errors.New("merger not found") } mergeCommit, err := s.Git.PerformMerge( diff --git a/internal/repository/patch.go b/internal/repository/patch.go index ca185f7..58680f4 100644 --- a/internal/repository/patch.go +++ b/internal/repository/patch.go @@ -92,17 +92,20 @@ func (r *PatchRepositoryImpl) FindByID(id string) (models.PatchRequest, error) { } func (r *PatchRepositoryImpl) Update(patch *models.PatchRequest) error { - return r.db.Model(patch).Updates(map[string]interface{}{ - "title": patch.Title, - "body": patch.Body, - "state": patch.State, - "merge_commit_sha": patch.MergeCommitSHA, - "is_mergeable": patch.IsMergeable, - "base_sha": patch.BaseSHA, - "head_sha": patch.HeadSHA, - "merged_at": patch.MergedAt, - "closed_at": patch.ClosedAt, - }).Error + return r.db. + Model(patch). + Omit("Author", "Reviewers", "Comments", "Commits", "Files", "Reviews"). + Updates(map[string]interface{}{ + "title": patch.Title, + "body": patch.Body, + "state": patch.State, + "merge_commit_sha": patch.MergeCommitSHA, + "is_mergeable": patch.IsMergeable, + "base_sha": patch.BaseSHA, + "head_sha": patch.HeadSHA, + "merged_at": patch.MergedAt, + "closed_at": patch.ClosedAt, + }).Error } func (r *PatchRepositoryImpl) Delete(patch *models.PatchRequest) error { @@ -166,13 +169,15 @@ func (r *PatchRepositoryImpl) AddReviewer(patchID, userID string) error { func (r *PatchRepositoryImpl) RemoveReviewer(patchID, userID string) error { return r.db. + Unscoped(). Where("patch_id = ? AND user_id = ?", patchID, userID). Delete(&models.PatchReviewer{}).Error } func (r *PatchRepositoryImpl) FindReviewers(patchID string, dest *[]models.User) error { return r.db. - Joins("JOIN patch_reviewers ON patch_reviewers.user_id = users.id"). + Select("users.*"). + Joins("JOIN patch_reviewers ON patch_reviewers.user_id = users.id AND patch_reviewers.deleted_at IS NULL"). Where("patch_reviewers.patch_id = ?", patchID). Find(dest).Error } diff --git a/internal/repository/user.go b/internal/repository/user.go index 5665e05..85534e6 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -6,6 +6,7 @@ import ( "gitxyz/internal/models" "time" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -42,6 +43,9 @@ func checkUser(result *gorm.DB) (models.User, error) { // ambil model dari result if result.Statement != nil { if user, ok := result.Statement.Dest.(*models.User); ok { + if user.ID == uuid.Nil { + return models.User{}, gorm.ErrRecordNotFound + } return *user, nil } if userSlice, ok := result.Statement.Dest.(*[]models.User); ok && len(*userSlice) > 0 { From b0a61f17bf0c7204046e5a62cf87cae4632288da Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:55:25 +0700 Subject: [PATCH 48/49] feat(patch): add author existence validation and handle soft-deleted reviews --- internal/api/services/patch.go | 8 ++++++++ internal/repository/patch.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go index 1663609..77cb92d 100644 --- a/internal/api/services/patch.go +++ b/internal/api/services/patch.go @@ -456,6 +456,10 @@ func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID if err != nil { return nil, err } + // Validate the author exists to avoid a foreign key violation. + if _, err := s.Users.FindByID(authorID); err != nil { + return nil, errors.New("author not found") + } isRev, err := s.Patches.IsReviewer(patch.ID.String(), authorID) if err != nil { return nil, err @@ -492,6 +496,10 @@ func (s *PatchServiceImpl) CreateComment(owner, name string, number int, authorI if err != nil { return nil, err } + // Validate the author exists to avoid a foreign key violation. + if _, err := s.Users.FindByID(authorID); err != nil { + return nil, errors.New("author not found") + } comment := &models.PatchComment{ PatchID: patch.ID.String(), AuthorID: authorID, diff --git a/internal/repository/patch.go b/internal/repository/patch.go index 58680f4..1d6dc9a 100644 --- a/internal/repository/patch.go +++ b/internal/repository/patch.go @@ -179,6 +179,7 @@ func (r *PatchRepositoryImpl) FindReviewers(patchID string, dest *[]models.User) Select("users.*"). Joins("JOIN patch_reviewers ON patch_reviewers.user_id = users.id AND patch_reviewers.deleted_at IS NULL"). Where("patch_reviewers.patch_id = ?", patchID). + Where("users.deleted_at IS NULL"). Find(dest).Error } @@ -202,6 +203,13 @@ func (r *PatchRepositoryImpl) UpsertReview(review *models.PatchReview) error { if err != nil { return err } + // A soft-deleted row was found: hard-replace it with the new review. + if existing.DeletedAt.Valid { + if err := r.db.Unscoped().Delete(&existing).Error; err != nil { + return err + } + return r.db.Create(review).Error + } existing.State = review.State existing.Body = review.Body return r.db.Model(&existing).Updates(map[string]interface{}{ From ba6d332ef55eaeb5d31f241df13067dd63cd6699 Mon Sep 17 00:00:00 2001 From: Billal Fauzan Date: Mon, 20 Jul 2026 13:55:32 +0700 Subject: [PATCH 49/49] feat(patch): enhance patch state validation and reviewer assignment logic --- internal/api/services/patch.go | 34 +++++++++++++++++++++++++++++++++- internal/repository/patch.go | 15 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go index 77cb92d..c005d14 100644 --- a/internal/api/services/patch.go +++ b/internal/api/services/patch.go @@ -201,12 +201,25 @@ func (s *PatchServiceImpl) UpdatePatch(owner, name string, number int, title, bo if body != "" { patch.Body = body } - if state != "" && models.ValidPatchState(state) { + if state != "" { + if !models.ValidPatchState(state) { + return nil, errors.New("invalid patch state") + } + // State transition rules: + // - open -> open | closed | merged + // - merged/closed are terminal: cannot be reopened. + if patch.State != models.PatchStateOpen && state != patch.State { + return nil, errors.New("patch is not open; state cannot be changed") + } patch.State = state if state == models.PatchStateClosed && patch.ClosedAt == nil { now := time.Now() patch.ClosedAt = &now } + if state == models.PatchStateMerged && patch.MergedAt == nil { + now := time.Now() + patch.MergedAt = &now + } } if err := s.Patches.Update(&patch); err != nil { return nil, err @@ -381,6 +394,14 @@ func (s *PatchServiceImpl) AssignReviewer(owner, name string, number int, userna if err != nil { return nil, errors.New("user not found") } + // Prevent duplicate assignment. + isRev, err := s.Patches.IsReviewer(patch.ID.String(), user.ID.String()) + if err != nil { + return nil, err + } + if isRev { + return nil, errors.New("reviewer already assigned") + } if err := s.Patches.AddReviewer(patch.ID.String(), user.ID.String()); err != nil { return nil, err } @@ -467,6 +488,17 @@ func (s *PatchServiceImpl) SubmitReview(owner, name string, number int, authorID if !isRev { return nil, errors.New("only assigned reviewers can submit a review") } + // Review transition rules: + // - Once a reviewer has APPROVED, they cannot later request changes. + // - changes_requested (request change) may be submitted repeatedly. + // - commented is neutral and may be overwritten anytime. + existing, err := s.Patches.FindReview(patch.ID.String(), authorID) + if err != nil { + return nil, err + } + if existing != nil && existing.State == models.PatchReviewApproved && state == models.PatchReviewChangesRequested { + return nil, errors.New("cannot request changes after approving") + } review := &models.PatchReview{ PatchID: patch.ID.String(), AuthorID: authorID, diff --git a/internal/repository/patch.go b/internal/repository/patch.go index 1d6dc9a..99f2055 100644 --- a/internal/repository/patch.go +++ b/internal/repository/patch.go @@ -34,6 +34,7 @@ type PatchRepository interface { // Reviews UpsertReview(review *models.PatchReview) error FindReviews(patchID string, dest *[]models.PatchReview) error + FindReview(patchID, authorID string) (*models.PatchReview, error) // Comments CreateComment(comment *models.PatchComment) error @@ -226,6 +227,20 @@ func (r *PatchRepositoryImpl) FindReviews(patchID string, dest *[]models.PatchRe Find(dest).Error } +func (r *PatchRepositoryImpl) FindReview(patchID, authorID string) (*models.PatchReview, error) { + var review models.PatchReview + err := r.db. + Where("patch_id = ? AND author_id = ?", patchID, authorID). + First(&review).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &review, nil +} + func (r *PatchRepositoryImpl) CreateComment(comment *models.PatchComment) error { return r.db.Create(comment).Error }