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..59f6903 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ volumes tmp .idea ./gitxyz -.envs \ No newline at end of file +.env + +.agents +.claude + +docs/superpowers +gitxyz \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..893f3f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# 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. +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 + +- [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/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 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/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); 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; 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); 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); 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); 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. diff --git a/go.mod b/go.mod index 6031d49..c784527 100644 --- a/go.mod +++ b/go.mod @@ -4,35 +4,30 @@ 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/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 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 +35,17 @@ 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/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 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..bf67b6d 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,35 @@ +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/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/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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +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= @@ -31,16 +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/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-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= @@ -55,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= @@ -76,34 +83,42 @@ 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= 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/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/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= @@ -114,8 +129,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 +144,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= @@ -147,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= @@ -161,18 +183,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/auth/jwt.go b/internal/api/auth/jwt.go index 62861e1..7e05c76 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,19 @@ 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"` + Role string `json:"role"` + 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 +42,30 @@ 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, + Role: user.Role, + 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 +74,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 +96,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..fb2abf7 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" ) @@ -18,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) @@ -76,27 +81,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/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/controllers/patch.go b/internal/api/controllers/patch.go new file mode 100644 index 0000000..0a8c558 --- /dev/null +++ b/internal/api/controllers/patch.go @@ -0,0 +1,300 @@ +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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "patches listed", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteCreated(ctx, "patch created", 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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "patch found", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteOK(ctx, "patch updated", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteOK(ctx, "patch refreshed", 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 { + response.WriteError(ctx, http.StatusConflict, err.Error()) + return + } + response.WriteOK(ctx, "patch merged", 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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "commits listed", 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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "files listed", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + patch, err := c.service.AssignReviewer(owner, repo, n, req.Username) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteOK(ctx, "reviewer assigned", response.ToPatchResponse(patch)) +} + +func (c *PatchControllerImpl) UnassignReviewer(ctx *gin.Context) { + owner, repo := c.ownerRepo(ctx) + n, ok := c.number(ctx) + if !ok { + return + } + username := ctx.Param("username") + patch, err := c.service.UnassignReviewer(owner, repo, n, username) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteOK(ctx, "reviewer unassigned", response.ToPatchResponse(patch)) +} + +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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "reviewers listed", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + authorID := ctx.GetString("user_id") + patch, err := c.service.SubmitReview(owner, repo, n, authorID, req.State, req.Body) + if err != nil { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteOK(ctx, "review submitted", response.ToPatchResponse(patch)) +} + +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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "reviews listed", 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 { + response.WriteError(ctx, http.StatusBadRequest, err.Error()) + return + } + response.WriteCreated(ctx, "comment created", 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 { + response.WriteError(ctx, http.StatusNotFound, err.Error()) + return + } + response.WriteOK(ctx, "comments listed", response.ToPatchCommentResponseSlice(comments)) +} 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 cbc7cb2..7ea44b3 100644 --- a/internal/api/controllers/repo.go +++ b/internal/api/controllers/repo.go @@ -1,22 +1,434 @@ 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" ) -type RepoController interface{} +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) + RemoveCollaborator(ctx *gin.Context) + + ListPolicies(ctx *gin.Context) + AddPolicy(ctx *gin.Context) + RemovePolicy(ctx *gin.Context) +} + type RepoControllerImpl struct { service services.RepoService + git services.GitQueryService db *gorm.DB } func NewRepoController(db *gorm.DB) RepoController { - service := services.NewRepoService() + service := services.NewRepoService(db) return &RepoControllerImpl{ service: service, + git: services.NewGitQueryService(db), 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 + } + owner, _ := ctx.Get("username") + + 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": 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) 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") + + 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/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/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/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/request/user.go b/internal/api/dto/request/user.go index c3bba91..7b0c0ab 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 { @@ -27,3 +27,26 @@ 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"` +} + +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"` +} 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/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/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/repo.go b/internal/api/dto/response/repo.go new file mode 100644 index 0000000..9aa438b --- /dev/null +++ b/internal/api/dto/response/repo.go @@ -0,0 +1,98 @@ +package dto + +import ( + "gitxyz/internal/models" + "time" +) + +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"` +} + +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 +} + +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/dto/response/user.go b/internal/api/dto/response/user.go index 9ca6d73..638d460 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"` @@ -30,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 +} diff --git a/internal/api/middlewares/auth.go b/internal/api/middlewares/auth.go index 93689fe..a09e954 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,9 +40,29 @@ 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() + } + } + } + + // 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) + 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..228f81f --- /dev/null +++ b/internal/api/middlewares/rbac.go @@ -0,0 +1,192 @@ +package middlewares + +import ( + "net/http" + + "gitxyz/internal/api/services" + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "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 + } + // 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) { + 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"}) + } +} + +// 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/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) + } +} 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/api/routes/auth.go b/internal/api/routes/auth.go index a729a05..fc1175d 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -3,23 +3,48 @@ package routes import ( "gitxyz/internal/api/controllers" "gitxyz/internal/api/middlewares" + "gitxyz/internal/models" ) 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("/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 := 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 := r.engine.Group("/api/user") + user.Use(middlewares.AuthRequired()) + + user.GET("", authController.Profile) + user.PATCH("", authController.UpdateProfile) + user.POST("/change-password", authController.ChangePassword) + + user.GET("/keys", userController.ListSSHKeys) + 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) + + // 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/routes/repo.go b/internal/api/routes/repo.go index 0db51ae..c57f214 100644 --- a/internal/api/routes/repo.go +++ b/internal/api/routes/repo.go @@ -1 +1,110 @@ package routes + +import ( + "gitxyz/internal/api/controllers" + "gitxyz/internal/api/middlewares" + "gitxyz/internal/models" +) + +func (r *RoutesImpl) RegisterRepositories() { + controller := controllers.NewRepoController(r.db) + + // /api/repos — repository collection. + repos := r.engine.Group("/api/repos") + 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()) + + // 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) + + // 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) + 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) + + // Issues — nested under the repository group. + issueController := controllers.NewIssueController(r.db) + + // 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. + 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. + 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. + 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) + + // 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/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/git.go b/internal/api/services/git.go new file mode 100644 index 0000000..469557d --- /dev/null +++ b/internal/api/services/git.go @@ -0,0 +1,413 @@ +package services + +import ( + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "gitxyz/internal/helper" + "gitxyz/pkg/git" + + "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. +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) + + // 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 { + 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, "/") +} + +// 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/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 +} diff --git a/internal/api/services/patch.go b/internal/api/services/patch.go new file mode 100644 index 0000000..c005d14 --- /dev/null +++ b/internal/api/services/patch.go @@ -0,0 +1,594 @@ +package services + +import ( + "errors" + "fmt" + "time" + + "gitxyz/internal/models" + "gitxyz/internal/repository" + + "github.com/google/uuid" + "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) (*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) (*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) + 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) { + // 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 + } + + // 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 != "" { + 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 + } + 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 || merger.ID == uuid.Nil { + return nil, errors.New("merger not found") + } + + 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) (*models.PatchRequest, error) { + patch, err := s.GetPatch(owner, name, number) + if err != nil { + return nil, err + } + user, err := s.Users.FindByUsername(username) + 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 + } + return s.GetPatch(owner, name, number) +} + +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 nil, err + } + user, err := s.Users.FindByUsername(username) + if err != nil { + 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.GetPatch(owner, name, number) +} + +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) (*models.PatchRequest, error) { + if !models.ValidPatchReviewState(state) { + return nil, errors.New("invalid review state") + } + patch, err := s.GetPatch(owner, name, number) + 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 + } + 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, + State: state, + Body: body, + } + 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) { + 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 + } + // 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, + 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/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.go b/internal/api/services/repo.go index 15846ba..1d58952 100644 --- a/internal/api/services/repo.go +++ b/internal/api/services/repo.go @@ -1,8 +1,235 @@ package services -type RepoService interface{} -type RepoServiceImpl struct{} +import ( + "errors" + "gitxyz/internal/models" + "gitxyz/internal/repository" -func NewRepoService() RepoService { - return &RepoServiceImpl{} + "gorm.io/gorm" +) + +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 + + // 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), + Members: repository.NewRepoMemberRepository(db), + Policies: repository.NewPolicyRepository(db), + Users: repository.NewUserRepository(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") + } + + // 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) +} + +// 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) 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 { + 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/repo_test.go b/internal/api/services/repo_test.go new file mode 100644 index 0000000..ef03ec3 --- /dev/null +++ b/internal/api/services/repo_test.go @@ -0,0 +1,113 @@ +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 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 { + 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.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 new file mode 100644 index 0000000..af77b7a --- /dev/null +++ b/internal/api/services/user.go @@ -0,0 +1,125 @@ +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") + } + + if err := models.ValidateScopes(scopes); err != nil { + return models.PersonalAccessToken{}, "", err + } + + 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 *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/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/init.go b/internal/bootstrap/init.go index 82330c0..2c32261 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -1,14 +1,25 @@ 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()) + server.Use(middlewares.InjectDB(db)) MakeRouter(server, db) 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/internal/bootstrap/router.go b/internal/bootstrap/router.go index cbe6649..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" @@ -10,4 +11,7 @@ import ( func MakeRouter(engine *gin.Engine, db *gorm.DB) { router := routes.NewRoutes(engine, db) router.RegisterAuth() + router.RegisterRepositories() + + githttp.RegisterRoutes(engine, db) } diff --git a/internal/helper/path.go b/internal/helper/path.go new file mode 100644 index 0000000..34afd9e --- /dev/null +++ b/internal/helper/path.go @@ -0,0 +1,16 @@ +package helper + +import ( + "path/filepath" + + "github.com/spf13/viper" +) + +// 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, repoID) +} 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/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") + } +} 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/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/models/keys.go b/internal/models/keys.go new file mode 100644 index 0000000..6b6c962 --- /dev/null +++ b/internal/models/keys.go @@ -0,0 +1,84 @@ +package models + +import ( + "fmt" + "strings" + "time" +) + +// 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" } + +// 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 +} + +// 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/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/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/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.go b/internal/models/repo.go index 003ee4b..0f2c4bc 100644 --- a/internal/models/repo.go +++ b/internal/models/repo.go @@ -3,10 +3,10 @@ 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"` - IsPrivate bool `json:"is_private" gorm:"not null;default:false"` + 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/models/repo_member.go b/internal/models/repo_member.go new file mode 100644 index 0000000..d7ce11e --- /dev/null +++ b/internal/models/repo_member.go @@ -0,0 +1,44 @@ +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" } + +// 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/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 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 +} 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 diff --git a/internal/repository/patch.go b/internal/repository/patch.go new file mode 100644 index 0000000..99f2055 --- /dev/null +++ b/internal/repository/patch.go @@ -0,0 +1,270 @@ +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 + FindReview(patchID, authorID string) (*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). + 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 { + 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. + 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. + 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 +} + +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 + } + // 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{}{ + "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) 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 +} + +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}) + }) +} 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.go b/internal/repository/repo.go index f9e4e5b..a3456c4 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -8,8 +8,14 @@ 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) + 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 } type RepoRepositoryImpl struct { @@ -22,7 +28,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 +36,52 @@ 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 + } -func (r *RepoRepositoryImpl) FindById(id string) (repo models.Repository, err error) { - result := r.db.Find(&repo, id) + return *repo, nil +} - return check(result) +func (r *RepoRepositoryImpl) Create(repo *models.Repository) error { + return r.db.Create(repo).Error } -func (r *RepoRepositoryImpl) FindByPhysicalPath(path string) (repo models.Repository, err error) { - result := r.db.Find(&repo, "physical_path = ?", path) +func (r *RepoRepositoryImpl) FindById(id string) (repo models.Repository, err error) { + result := r.db.First(&repo, "id = ?", id) - 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 +} + +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) 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, + "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 } 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) + } +} 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 { 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() 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 83cacae..81ab20c 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,12 +27,28 @@ 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, + } + + 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 @@ -43,29 +63,19 @@ func MakeOptionsFromContext(ctx *gin.Context) Options { options.ServiceType = "" } - username := ctx.Param("username") - options.UserName = username - - reponame := ctx.Param("reponame") - options.RepoName = strings.TrimSuffix(reponame, ".git") - 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 ebdb071..1230e6a 100644 --- a/modules/githttp/middlewares/auth.go +++ b/modules/githttp/middlewares/auth.go @@ -1,23 +1,85 @@ package middlewares import ( + "log/slog" + "net/http" + + "gitxyz/internal/helper" + "gitxyz/internal/logger" + "gitxyz/internal/repository" + githttpHelper "gitxyz/modules/githttp/helper" + "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 -} +func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { + userRepo := repository.NewUserRepository(db) + patRepo := repository.NewPATRepository(db) + repoRepo := repository.NewRepoRepository(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) + 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 } - ctx.Set("username", username) + + 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", + }) + 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.Next() } } + +// 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, +) (authResult, bool) { + if user, err := userRepo.Authenticate(username, secret); err == nil && user.ID != [16]byte{} { + return authResult{Role: user.Role, TokenType: "jwt"}, true + } + if pat, err := patRepo.FindByTokenHash(helper.HashToken(secret)); err == nil { + return authResult{Role: "", Scopes: pat.Scopes, TokenType: "pat"}, true + } + return authResult{}, false +} diff --git a/modules/githttp/routes.go b/modules/githttp/routes.go index bba84b3..ce397e3 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,9 +12,10 @@ func RegisterRoutes(server *gin.Engine, tx *gorm.DB) { controller := NewController(tx) routes := server.Group("/:username/:reponame") - - routes.Use(middlewares.AuthMiddleware()) + 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) + routes.Match([]string{"POST", "OPTIONS"}, "/git-upload-pack", controller.UploadPack) } diff --git a/modules/githttp/services/authorize.go b/modules/githttp/services/authorize.go index 71833e0..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,22 +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: - if !permission.CanRead(options.UserName, options.RepoName) { - ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "read access denied"}) + // 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: - 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(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 } diff --git a/modules/githttp/services/info-refs.go b/modules/githttp/services/info-refs.go index 5a1334f..6f878dd 100644 --- a/modules/githttp/services/info-refs.go +++ b/modules/githttp/services/info-refs.go @@ -2,6 +2,10 @@ package services import ( "fmt" + "log/slog" + "net/http" + + "gitxyz/internal/logger" "gitxyz/modules/githttp/helper" "gitxyz/pkg/git" @@ -9,11 +13,21 @@ import ( ) func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { - options := helper.MakeOptionsFromContext(ctx) + 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 + } - physical_path, err := options.EnsureRepositoryStorage() + 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 } authorized := s.Authorize(ctx, options) @@ -22,25 +36,33 @@ func (s *GitServiceImpl) GetInfoRefs(ctx *gin.Context) { } cmd := git.NewCommand() - isBareRepository, err := cmd.IsBareRepository(physical_path) + 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(physical_path); err != nil { + 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(physical_path) + 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 51a7810..2edc32c 100644 --- a/modules/githttp/services/permission.go +++ b/modules/githttp/services/permission.go @@ -1,10 +1,20 @@ package services -import "gorm.io/gorm" +import ( + "log/slog" + + "gitxyz/internal/api/services" + "gitxyz/internal/logger" + "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 { @@ -12,17 +22,181 @@ type PermissionImpl struct { } func NewPermission(db *gorm.DB) Permission { - return &PermissionImpl{ - db: db, + return &PermissionImpl{db: db} +} + +// 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")) + 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("user_id") == repo.UserID { + log.Debug("permission: creator read allowed", slog.String("repo", reponame)) + return true + } + allowed := a.memberCan(repo.ID.String(), userID, readRoles) + log.Debug("permission: membership read check", slog.String("repo", reponame), slog.Bool("allowed", allowed)) + return allowed } -func (a *PermissionImpl) CanRead(username string, reponame string) bool { - // TODO: Implement function for authorization repository - return true // placeholder +// 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 { + 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")) + 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("user_id") == repo.UserID { + log.Debug("permission: creator write allowed", slog.String("repo", reponame)) + return true + } + 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 ( + 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 } -func (a *PermissionImpl) CanWrite(username string, reponame string) bool { - // TODO: Implement function for authorization repository - return true // placeholder +// 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 + } + 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..c72c582 --- /dev/null +++ b/modules/githttp/services/permission_test.go @@ -0,0 +1,162 @@ +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"}) + owner, _ := userRepo.FindByUsername("owner1") + repoRepo := repository.NewRepoRepository(db) + _ = 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", "") + + 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") + } +} diff --git a/modules/githttp/services/receive-pack.go b/modules/githttp/services/receive-pack.go index 59f0cb0..7cd959c 100644 --- a/modules/githttp/services/receive-pack.go +++ b/modules/githttp/services/receive-pack.go @@ -1,25 +1,58 @@ 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) - options := helper.MakeOptionsFromContext(ctx) + 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 + } + + 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"))) 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(storagePath, 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"))) } diff --git a/modules/githttp/services/upload-pack.go b/modules/githttp/services/upload-pack.go index b78f895..cb861eb 100644 --- a/modules/githttp/services/upload-pack.go +++ b/modules/githttp/services/upload-pack.go @@ -1,7 +1,57 @@ package services -import "github.com/gin-gonic/gin" +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) + 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"))) } 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, 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" + } + } +}