From 684c30f8b8d3408bbbba6138533ba5043dc4c5af Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Thu, 5 Mar 2026 18:55:55 -0800 Subject: [PATCH] Reorganize internals --- cmd/cairn-server/main.go | 8 +- cmd/cairn/integration_test.go | 255 ++++++++++++++ cmd/cairn/main.go | 8 + go.mod | 9 +- go.sum | 16 + internal/api/types.go | 89 +++++ internal/config/config.go | 59 +--- internal/config/config_test.go | 80 +++++ internal/database/database.go | 26 +- internal/database/migrate.go | 86 +---- internal/database/migrations/001_initial.sql | 56 ---- .../database/migrations/002_crash_groups.sql | 56 ---- .../database/migrations/003_campaigns.sql | 18 - internal/forgejo/client.go | 7 +- internal/forgejo/sync.go | 29 +- internal/forgejo/webhooks_test.go | 97 ++++++ internal/handler/campaigns.go | 35 +- internal/handler/crashgroups.go | 39 ++- internal/handler/dashboard.go | 91 ++--- internal/handler/idparse.go | 25 ++ internal/handler/idparse_test.go | 38 +++ internal/handler/ingest.go | 40 ++- internal/handler/regression.go | 8 +- internal/handler/search.go | 33 +- internal/models/artifact.go | 208 +++++++----- internal/models/campaign.go | 165 ++++----- internal/models/commit.go | 22 -- internal/models/crash_group.go | 314 ++++++------------ internal/models/models.go | 137 ++++++-- internal/models/repository.go | 73 ++-- internal/regression/regression.go | 35 +- internal/regression/regression_test.go | 17 + internal/web/routes.go | 25 +- internal/web/web.go | 169 ++++++---- 34 files changed, 1409 insertions(+), 964 deletions(-) create mode 100644 cmd/cairn/integration_test.go create mode 100644 internal/api/types.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/database/migrations/001_initial.sql delete mode 100644 internal/database/migrations/002_crash_groups.sql delete mode 100644 internal/database/migrations/003_campaigns.sql create mode 100644 internal/forgejo/webhooks_test.go create mode 100644 internal/handler/idparse.go create mode 100644 internal/handler/idparse_test.go delete mode 100644 internal/models/commit.go create mode 100644 internal/regression/regression_test.go diff --git a/cmd/cairn-server/main.go b/cmd/cairn-server/main.go index a02a939..92d31fa 100644 --- a/cmd/cairn-server/main.go +++ b/cmd/cairn-server/main.go @@ -29,13 +29,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - pool, err := database.Connect(ctx, cfg.DatabaseURL) + db, err := database.Connect(cfg.DatabaseURL) if err != nil { log.Fatal().Err(err).Msg("Connecting to database") } - defer pool.Close() - if err := database.Migrate(ctx, pool); err != nil { + if err := database.Migrate(db); err != nil { log.Fatal().Err(err).Msg("Running migrations") } @@ -55,9 +54,10 @@ func main() { } router, err := web.NewRouter(web.RouterConfig{ - Pool: pool, + DB: db, Store: store, ForgejoClient: forgejoClient, + ForgejoURL: cfg.ForgejoURL, WebhookSecret: cfg.ForgejoWebhookSecret, }) if err != nil { diff --git a/cmd/cairn/integration_test.go b/cmd/cairn/integration_test.go new file mode 100644 index 0000000..750bea9 --- /dev/null +++ b/cmd/cairn/integration_test.go @@ -0,0 +1,255 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + "github.com/mattnite/cairn/internal/blob" + "github.com/mattnite/cairn/internal/database" + "github.com/mattnite/cairn/internal/forgejo" + "github.com/mattnite/cairn/internal/models" + "github.com/mattnite/cairn/internal/web" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type memoryBlobStore struct { + mu sync.RWMutex + data map[string][]byte +} + +var _ blob.Store = (*memoryBlobStore)(nil) + +func newMemoryBlobStore() *memoryBlobStore { + return &memoryBlobStore{data: make(map[string][]byte)} +} + +func (s *memoryBlobStore) Put(_ context.Context, key string, reader io.Reader, _ int64) error { + b, err := io.ReadAll(reader) + if err != nil { + return err + } + s.mu.Lock() + s.data[key] = b + s.mu.Unlock() + return nil +} + +func (s *memoryBlobStore) Get(_ context.Context, key string) (io.ReadCloser, error) { + s.mu.RLock() + b, ok := s.data[key] + s.mu.RUnlock() + if !ok { + return nil, io.EOF + } + return io.NopCloser(bytes.NewReader(b)), nil +} + +func (s *memoryBlobStore) Delete(_ context.Context, key string) error { + s.mu.Lock() + delete(s.data, key) + s.mu.Unlock() + return nil +} + +type forgejoMock struct { + server *httptest.Server + mu sync.Mutex + issueCalls int +} + +func newForgejoMock(t *testing.T) *forgejoMock { + t.Helper() + m := &forgejoMock{} + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/repos/") && strings.HasSuffix(r.URL.Path, "/issues") { + m.mu.Lock() + m.issueCalls++ + m.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"number":123,"html_url":"http://mock/issues/123"}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(m.server.Close) + return m +} + +func (m *forgejoMock) calls() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.issueCalls +} + +func setupCLIServer(t *testing.T, enableForgejo bool) (serverURL string, db *gorm.DB, cleanup func(), mock *forgejoMock) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "cairn-test.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + t.Fatalf("opening sqlite db: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("migrating db: %v", err) + } + + store := newMemoryBlobStore() + + var forgejoClient *forgejo.Client + forgejoURL := "" + if enableForgejo { + mock = newForgejoMock(t) + forgejoURL = mock.server.URL + forgejoClient = forgejo.NewClient(mock.server.URL, "token") + } + + router, err := web.NewRouter(web.RouterConfig{ + DB: db, + Store: store, + ForgejoClient: forgejoClient, + ForgejoURL: forgejoURL, + }) + if err != nil { + t.Fatalf("creating router: %v", err) + } + + srv := httptest.NewServer(router) + cleanup = srv.Close + return srv.URL, db, cleanup, mock +} + +func TestCLIUploadAndDownloadRoundTrip(t *testing.T) { + serverURL, db, cleanup, _ := setupCLIServer(t, false) + defer cleanup() + + artifactFile := filepath.Join(t.TempDir(), "artifact.bin") + original := []byte("artifact bytes") + if err := os.WriteFile(artifactFile, original, 0o644); err != nil { + t.Fatalf("writing test artifact: %v", err) + } + + err := cmdUpload([]string{ + "-server", serverURL, + "-repo", "demo", + "-owner", "acme", + "-commit", "abcdef1234567890", + "-type", "fuzz", + "-file", artifactFile, + "-crash-message", "boom", + "-signal", "11", + }) + if err != nil { + t.Fatalf("cmdUpload failed: %v", err) + } + + var a models.Artifact + if err := db.First(&a).Error; err != nil { + t.Fatalf("querying uploaded artifact: %v", err) + } + + var md map[string]any + if err := json.Unmarshal(a.Metadata, &md); err != nil { + t.Fatalf("unmarshal metadata: %v", err) + } + if got := md["signal"]; got != "11" { + t.Fatalf("expected metadata.signal=11, got %#v", got) + } + + outFile := filepath.Join(t.TempDir(), "downloaded.bin") + if err := cmdDownload([]string{"-server", serverURL, "-id", strconv.FormatUint(uint64(a.ID), 10), "-o", outFile}); err != nil { + t.Fatalf("cmdDownload failed: %v", err) + } + + downloaded, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading downloaded artifact: %v", err) + } + if !bytes.Equal(downloaded, original) { + t.Fatalf("downloaded content mismatch: got %q want %q", downloaded, original) + } +} + +func TestCLICampaignStartAndFinish(t *testing.T) { + serverURL, db, cleanup, _ := setupCLIServer(t, false) + defer cleanup() + + if err := cmdCampaign("start", []string{ + "-server", serverURL, + "-repo", "demo", + "-owner", "acme", + "-name", "nightly", + "-type", "fuzz", + }); err != nil { + t.Fatalf("cmdCampaign start failed: %v", err) + } + + var campaign models.Campaign + if err := db.First(&campaign).Error; err != nil { + t.Fatalf("querying campaign: %v", err) + } + if campaign.Status != "running" { + t.Fatalf("expected running campaign, got %q", campaign.Status) + } + + if err := cmdCampaign("finish", []string{ + "-server", serverURL, + "-id", strconv.FormatUint(uint64(campaign.ID), 10), + }); err != nil { + t.Fatalf("cmdCampaign finish failed: %v", err) + } + + if err := db.First(&campaign, campaign.ID).Error; err != nil { + t.Fatalf("re-querying campaign: %v", err) + } + if campaign.Status != "finished" { + t.Fatalf("expected finished campaign, got %q", campaign.Status) + } +} + +func TestCLIUploadTriggersForgejoIssueCreation(t *testing.T) { + serverURL, db, cleanup, mock := setupCLIServer(t, true) + defer cleanup() + + artifactFile := filepath.Join(t.TempDir(), "crash.txt") + if err := os.WriteFile(artifactFile, []byte("crash payload"), 0o644); err != nil { + t.Fatalf("writing test artifact: %v", err) + } + + stack := "at foo crash.c:10\nat bar crash.c:20\n" + err := cmdUpload([]string{ + "-server", serverURL, + "-repo", "demo", + "-owner", "acme", + "-commit", "abcdef1234567890", + "-type", "fuzz", + "-file", artifactFile, + "-stack-trace", stack, + "-crash-message", "boom", + }) + if err != nil { + t.Fatalf("cmdUpload failed: %v", err) + } + + if got := mock.calls(); got != 1 { + t.Fatalf("expected 1 forgejo issue call, got %d", got) + } + + var cg models.CrashGroup + if err := db.First(&cg).Error; err != nil { + t.Fatalf("querying crash group: %v", err) + } + if cg.ForgejoIssueID == nil || *cg.ForgejoIssueID != 123 { + t.Fatalf("expected forgejo_issue_id=123, got %#v", cg.ForgejoIssueID) + } +} diff --git a/cmd/cairn/main.go b/cmd/cairn/main.go index b39c753..c690754 100644 --- a/cmd/cairn/main.go +++ b/cmd/cairn/main.go @@ -73,6 +73,7 @@ Upload flags: -file PATH Path to artifact file (required) -crash-message MSG Crash message (optional) -stack-trace TRACE Stack trace text (optional) + -signal VALUE Crash signal number/name (optional, stored in metadata) -seed VALUE Simulation seed for reproducibility (optional, stored in metadata) -target NAME Target name/platform (optional, stored in metadata) `) @@ -88,6 +89,7 @@ func cmdUpload(args []string) error { filePath string crashMessage string stackTrace string + signal string seed string target string ) @@ -118,6 +120,9 @@ func cmdUpload(args []string) error { case "-stack-trace": i++ stackTrace = args[i] + case "-signal": + i++ + signal = args[i] case "-seed": i++ seed = args[i] @@ -146,6 +151,9 @@ func cmdUpload(args []string) error { meta["stack_trace"] = stackTrace } md := map[string]any{} + if signal != "" { + md["signal"] = signal + } if seed != "" { md["seed"] = seed } diff --git a/go.mod b/go.mod index 81de62c..be622ca 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.25.7 require ( github.com/gin-gonic/gin v1.12.0 - github.com/jackc/pgx/v5 v5.8.0 + github.com/kelseyhightower/envconfig v1.4.0 github.com/minio/minio-go/v7 v7.0.98 github.com/rs/zerolog v1.34.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 ) require ( @@ -26,7 +28,10 @@ require ( github.com/google/uuid v1.6.0 // 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.6.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + 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/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -34,6 +39,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -56,4 +62,5 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 0cba481..a2c88fc 100644 --- a/go.sum +++ b/go.sum @@ -42,12 +42,20 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +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/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -67,6 +75,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -142,3 +152,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV 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= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..d64e3f9 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "time" +) + +type Repository struct { + ID uint `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Commit struct { + ID uint `json:"id"` + RepositoryID uint `json:"repository_id"` + SHA string `json:"sha"` + Author *string `json:"author,omitempty"` + Message *string `json:"message,omitempty"` + Branch *string `json:"branch,omitempty"` + CommittedAt *time.Time `json:"committed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Artifact struct { + ID uint `json:"id"` + RepositoryID uint `json:"repository_id"` + CommitID uint `json:"commit_id"` + BuildID *uint `json:"build_id,omitempty"` + CampaignID *uint `json:"campaign_id,omitempty"` + CrashSignatureID *uint `json:"crash_signature_id,omitempty"` + Type string `json:"type"` + BlobKey string `json:"blob_key"` + BlobSize int64 `json:"blob_size"` + CrashMessage *string `json:"crash_message,omitempty"` + StackTrace *string `json:"stack_trace,omitempty"` + Tags json.RawMessage `json:"tags,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + + RepoName string `json:"repo_name,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` +} + +type Campaign struct { + ID uint `json:"id"` + RepositoryID uint `json:"repository_id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Tags json.RawMessage `json:"tags,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + + RepoName string `json:"repo_name,omitempty"` + ArtifactCount int64 `json:"artifact_count,omitempty"` +} + +type CrashSignature struct { + ID uint `json:"id"` + RepositoryID uint `json:"repository_id"` + Fingerprint string `json:"fingerprint"` + SampleTrace *string `json:"sample_trace,omitempty"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` + OccurrenceCount uint `json:"occurrence_count"` +} + +type CrashGroup struct { + ID uint `json:"id"` + RepositoryID uint `json:"repository_id"` + CrashSignatureID uint `json:"crash_signature_id"` + Title string `json:"title"` + Status string `json:"status"` + ForgejoIssueID *int `json:"forgejo_issue_id,omitempty"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + RepoName string `json:"repo_name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + OccurrenceCount uint `json:"occurrence_count,omitempty"` + ForgejoIssueURL *string `json:"forgejo_issue_url,omitempty"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 7cff110..743c130 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,62 +2,35 @@ package config import ( "fmt" - "os" - "strconv" + + "github.com/kelseyhightower/envconfig" ) type Config struct { - ListenAddr string + ListenAddr string `envconfig:"CAIRN_LISTEN_ADDR" default:":8080"` - DatabaseURL string + DatabaseURL string `envconfig:"CAIRN_DATABASE_URL" default:"postgres://cairn:cairn@localhost:5432/cairn?sslmode=disable"` - S3Endpoint string - S3Bucket string - S3AccessKey string - S3SecretKey string - S3UseSSL bool + S3Endpoint string `envconfig:"CAIRN_S3_ENDPOINT" default:"localhost:9000"` + S3Bucket string `envconfig:"CAIRN_S3_BUCKET" default:"cairn-artifacts"` + S3AccessKey string `envconfig:"CAIRN_S3_ACCESS_KEY" default:"minioadmin"` + S3SecretKey string `envconfig:"CAIRN_S3_SECRET_KEY" default:"minioadmin"` + S3UseSSL bool `envconfig:"CAIRN_S3_USE_SSL" default:"false"` - ForgejoURL string - ForgejoToken string - ForgejoWebhookSecret string + ForgejoURL string `envconfig:"CAIRN_FORGEJO_URL"` + ForgejoToken string `envconfig:"CAIRN_FORGEJO_TOKEN"` + ForgejoWebhookSecret string `envconfig:"CAIRN_FORGEJO_WEBHOOK_SECRET"` } func Load() (*Config, error) { - c := &Config{ - ListenAddr: envOr("CAIRN_LISTEN_ADDR", ":8080"), - DatabaseURL: envOr("CAIRN_DATABASE_URL", "postgres://cairn:cairn@localhost:5432/cairn?sslmode=disable"), - S3Endpoint: envOr("CAIRN_S3_ENDPOINT", "localhost:9000"), - S3Bucket: envOr("CAIRN_S3_BUCKET", "cairn-artifacts"), - S3AccessKey: envOr("CAIRN_S3_ACCESS_KEY", "minioadmin"), - S3SecretKey: envOr("CAIRN_S3_SECRET_KEY", "minioadmin"), - S3UseSSL: envBool("CAIRN_S3_USE_SSL", false), - ForgejoURL: envOr("CAIRN_FORGEJO_URL", ""), - ForgejoToken: envOr("CAIRN_FORGEJO_TOKEN", ""), - ForgejoWebhookSecret: envOr("CAIRN_FORGEJO_WEBHOOK_SECRET", ""), + var c Config + if err := envconfig.Process("", &c); err != nil { + return nil, fmt.Errorf("loading environment config: %w", err) } if c.DatabaseURL == "" { return nil, fmt.Errorf("CAIRN_DATABASE_URL is required") } - return c, nil -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func envBool(key string, fallback bool) bool { - v := os.Getenv(key) - if v == "" { - return fallback - } - b, err := strconv.ParseBool(v) - if err != nil { - return fallback - } - return b + return &c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9b2ca1b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,80 @@ +package config + +import ( + "os" + "testing" +) + +func unsetEnvForTest(t *testing.T, key string) { + t.Helper() + prev, had := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("failed to unset %s: %v", key, err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, prev) + return + } + _ = os.Unsetenv(key) + }) +} + +func TestLoadDefaults(t *testing.T) { + unsetEnvForTest(t, "CAIRN_LISTEN_ADDR") + unsetEnvForTest(t, "CAIRN_DATABASE_URL") + unsetEnvForTest(t, "CAIRN_S3_ENDPOINT") + unsetEnvForTest(t, "CAIRN_S3_BUCKET") + unsetEnvForTest(t, "CAIRN_S3_ACCESS_KEY") + unsetEnvForTest(t, "CAIRN_S3_SECRET_KEY") + unsetEnvForTest(t, "CAIRN_S3_USE_SSL") + unsetEnvForTest(t, "CAIRN_FORGEJO_URL") + unsetEnvForTest(t, "CAIRN_FORGEJO_TOKEN") + unsetEnvForTest(t, "CAIRN_FORGEJO_WEBHOOK_SECRET") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.ListenAddr != ":8080" { + t.Fatalf("expected default listen addr, got %q", cfg.ListenAddr) + } + if cfg.DatabaseURL == "" { + t.Fatal("expected default database URL to be non-empty") + } + if cfg.S3UseSSL { + t.Fatal("expected default S3UseSSL=false") + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("CAIRN_LISTEN_ADDR", ":9090") + t.Setenv("CAIRN_DATABASE_URL", "postgres://test/test") + t.Setenv("CAIRN_S3_USE_SSL", "true") + t.Setenv("CAIRN_FORGEJO_URL", "https://forgejo.example") + t.Setenv("CAIRN_FORGEJO_TOKEN", "token") + t.Setenv("CAIRN_FORGEJO_WEBHOOK_SECRET", "secret") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.ListenAddr != ":9090" { + t.Fatalf("expected :9090, got %q", cfg.ListenAddr) + } + if cfg.DatabaseURL != "postgres://test/test" { + t.Fatalf("unexpected database URL: %q", cfg.DatabaseURL) + } + if !cfg.S3UseSSL { + t.Fatal("expected S3UseSSL=true") + } +} + +func TestLoadInvalidBoolReturnsError(t *testing.T) { + t.Setenv("CAIRN_S3_USE_SSL", "not-a-bool") + + _, err := Load() + if err == nil { + t.Fatal("expected error for invalid CAIRN_S3_USE_SSL") + } +} diff --git a/internal/database/database.go b/internal/database/database.go index 953948b..9750fbe 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,27 +1,19 @@ package database import ( - "context" "fmt" - "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) -func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { - config, err := pgxpool.ParseConfig(databaseURL) +func Connect(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) if err != nil { - return nil, fmt.Errorf("parsing database URL: %w", err) + return nil, fmt.Errorf("opening database: %w", err) } - - pool, err := pgxpool.NewWithConfig(ctx, config) - if err != nil { - return nil, fmt.Errorf("creating connection pool: %w", err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("pinging database: %w", err) - } - - return pool, nil + return db, nil } diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 6dbb60d..903f183 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -1,83 +1,23 @@ package database import ( - "context" - "embed" "fmt" - "io/fs" - "sort" - "strings" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/rs/zerolog/log" + "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) -//go:embed migrations/*.sql -var migrationsFS embed.FS - -func Migrate(ctx context.Context, pool *pgxpool.Pool) error { - _, err := pool.Exec(ctx, ` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version TEXT PRIMARY KEY, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `) - if err != nil { - return fmt.Errorf("creating migrations table: %w", err) +func Migrate(db *gorm.DB) error { + if err := db.AutoMigrate( + &models.Repository{}, + &models.Commit{}, + &models.Build{}, + &models.Campaign{}, + &models.CrashSignature{}, + &models.CrashGroup{}, + &models.Artifact{}, + ); err != nil { + return fmt.Errorf("running automigrate: %w", err) } - - entries, err := fs.ReadDir(migrationsFS, "migrations") - if err != nil { - return fmt.Errorf("reading migrations directory: %w", err) - } - - // Sort by filename to ensure order. - sort.Slice(entries, func(i, j int) bool { - return entries[i].Name() < entries[j].Name() - }) - - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { - continue - } - - version := strings.TrimSuffix(entry.Name(), ".sql") - - var exists bool - err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists) - if err != nil { - return fmt.Errorf("checking migration %s: %w", version, err) - } - if exists { - continue - } - - sql, err := migrationsFS.ReadFile("migrations/" + entry.Name()) - if err != nil { - return fmt.Errorf("reading migration %s: %w", version, err) - } - - tx, err := pool.Begin(ctx) - if err != nil { - return fmt.Errorf("beginning transaction for %s: %w", version, err) - } - - if _, err := tx.Exec(ctx, string(sql)); err != nil { - _ = tx.Rollback(ctx) - return fmt.Errorf("executing migration %s: %w", version, err) - } - - if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil { - _ = tx.Rollback(ctx) - return fmt.Errorf("recording migration %s: %w", version, err) - } - - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("committing migration %s: %w", version, err) - } - - log.Info().Str("version", version).Msg("Applied migration") - } - return nil } diff --git a/internal/database/migrations/001_initial.sql b/internal/database/migrations/001_initial.sql deleted file mode 100644 index 4d6c60b..0000000 --- a/internal/database/migrations/001_initial.sql +++ /dev/null @@ -1,56 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE repositories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - owner TEXT NOT NULL, - forgejo_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE commits ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - repository_id UUID NOT NULL REFERENCES repositories(id), - sha TEXT NOT NULL, - author TEXT, - message TEXT, - branch TEXT, - committed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (repository_id, sha) -); - -CREATE INDEX idx_commits_repo_sha ON commits (repository_id, sha); - -CREATE TABLE builds ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - repository_id UUID NOT NULL REFERENCES repositories(id), - commit_id UUID NOT NULL REFERENCES commits(id), - builder TEXT, - build_flags TEXT, - tags JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_builds_commit ON builds (commit_id); - -CREATE TABLE artifacts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - repository_id UUID NOT NULL REFERENCES repositories(id), - commit_id UUID NOT NULL REFERENCES commits(id), - build_id UUID REFERENCES builds(id), - type TEXT NOT NULL, - blob_key TEXT NOT NULL, - blob_size BIGINT NOT NULL, - crash_message TEXT, - stack_trace TEXT, - tags JSONB DEFAULT '{}', - metadata JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_artifacts_repo ON artifacts (repository_id); -CREATE INDEX idx_artifacts_commit ON artifacts (commit_id); -CREATE INDEX idx_artifacts_type ON artifacts (type); -CREATE INDEX idx_artifacts_created ON artifacts (created_at DESC); diff --git a/internal/database/migrations/002_crash_groups.sql b/internal/database/migrations/002_crash_groups.sql deleted file mode 100644 index 2a05088..0000000 --- a/internal/database/migrations/002_crash_groups.sql +++ /dev/null @@ -1,56 +0,0 @@ -CREATE TABLE crash_signatures ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - repository_id UUID NOT NULL REFERENCES repositories(id), - fingerprint TEXT NOT NULL, - sample_trace TEXT, - first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - occurrence_count INT NOT NULL DEFAULT 1, - UNIQUE (repository_id, fingerprint) -); - -CREATE INDEX idx_crash_signatures_repo ON crash_signatures (repository_id); -CREATE INDEX idx_crash_signatures_last_seen ON crash_signatures (last_seen_at DESC); - -CREATE TABLE crash_groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - crash_signature_id UUID NOT NULL UNIQUE REFERENCES crash_signatures(id), - repository_id UUID NOT NULL REFERENCES repositories(id), - title TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'open', - forgejo_issue_id INT, - forgejo_issue_url TEXT, - first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_crash_groups_repo ON crash_groups (repository_id); -CREATE INDEX idx_crash_groups_status ON crash_groups (status); -CREATE INDEX idx_crash_groups_sig ON crash_groups (crash_signature_id); - -ALTER TABLE artifacts ADD COLUMN signature_id UUID REFERENCES crash_signatures(id); -ALTER TABLE artifacts ADD COLUMN fingerprint TEXT; - -CREATE INDEX idx_artifacts_signature ON artifacts (signature_id); -CREATE INDEX idx_artifacts_fingerprint ON artifacts (fingerprint); - --- Full-text search support -ALTER TABLE artifacts ADD COLUMN search_vector tsvector; - -CREATE INDEX idx_artifacts_search ON artifacts USING GIN (search_vector); - -CREATE OR REPLACE FUNCTION artifacts_search_update() RETURNS trigger AS $$ -BEGIN - NEW.search_vector := - setweight(to_tsvector('english', COALESCE(NEW.crash_message, '')), 'A') || - setweight(to_tsvector('english', COALESCE(NEW.stack_trace, '')), 'B') || - setweight(to_tsvector('english', COALESCE(NEW.type, '')), 'C'); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER artifacts_search_trigger - BEFORE INSERT OR UPDATE ON artifacts - FOR EACH ROW EXECUTE FUNCTION artifacts_search_update(); diff --git a/internal/database/migrations/003_campaigns.sql b/internal/database/migrations/003_campaigns.sql deleted file mode 100644 index 1d7c763..0000000 --- a/internal/database/migrations/003_campaigns.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE campaigns ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - repository_id UUID NOT NULL REFERENCES repositories(id), - name TEXT NOT NULL, - type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'running', - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - finished_at TIMESTAMPTZ, - tags JSONB DEFAULT '{}', - metadata JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_campaigns_repo ON campaigns (repository_id); -CREATE INDEX idx_campaigns_status ON campaigns (status); - -ALTER TABLE artifacts ADD COLUMN campaign_id UUID REFERENCES campaigns(id); -CREATE INDEX idx_artifacts_campaign ON artifacts (campaign_id); diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index 10b36f6..2cad791 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) @@ -18,7 +19,7 @@ type Client struct { func NewClient(baseURL, token string) *Client { return &Client{ - baseURL: baseURL, + baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: &http.Client{ Timeout: 30 * time.Second, @@ -26,6 +27,10 @@ func NewClient(baseURL, token string) *Client { } } +func (c *Client) BaseURL() string { + return c.baseURL +} + // Issue represents a Forgejo issue. type Issue struct { ID int64 `json:"id"` diff --git a/internal/forgejo/sync.go b/internal/forgejo/sync.go index 0171402..892ebff 100644 --- a/internal/forgejo/sync.go +++ b/internal/forgejo/sync.go @@ -5,8 +5,9 @@ import ( "fmt" "strings" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" "github.com/rs/zerolog/log" + "gorm.io/gorm" "github.com/mattnite/cairn/internal/models" ) @@ -14,16 +15,16 @@ import ( // Sync handles bidirectional state synchronization between Cairn and Forgejo. type Sync struct { Client *Client - Pool *pgxpool.Pool + DB *gorm.DB } // CreateIssueForCrashGroup creates a Forgejo issue for a new crash group. -func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *models.CrashGroup, sampleTrace string) error { +func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *cairnapi.CrashGroup, sampleTrace string) error { if s.Client == nil { return nil } - repo, err := models.GetRepositoryByID(ctx, s.Pool, group.RepositoryID) + repo, err := models.GetRepositoryByID(ctx, s.DB, group.RepositoryID) if err != nil { return fmt.Errorf("getting repository: %w", err) } @@ -52,7 +53,15 @@ func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *models.Crash return fmt.Errorf("creating issue: %w", err) } - return models.UpdateCrashGroupIssue(ctx, s.Pool, group.ID, issue.Number, issue.HTMLURL) + if err := s.DB.WithContext(ctx). + Model(&models.CrashGroup{}). + Where("id = ?", group.ID). + Update("forgejo_issue_id", issue.Number).Error; err != nil { + return err + } + issueURL := fmt.Sprintf("%s/%s/%s/issues/%d", strings.TrimRight(s.Client.BaseURL(), "/"), repo.Owner, repo.Name, issue.Number) + group.ForgejoIssueURL = &issueURL + return nil } // HandleIssueEvent processes a Forgejo issue webhook event for state sync. @@ -68,9 +77,15 @@ func (s *Sync) HandleIssueEvent(ctx context.Context, event *WebhookEvent) error switch event.Action { case "closed": - return models.ResolveCrashGroupByIssue(ctx, s.Pool, event.Issue.Number) + return s.DB.WithContext(ctx). + Model(&models.CrashGroup{}). + Where("forgejo_issue_id = ?", event.Issue.Number). + Update("status", "resolved").Error case "reopened": - return models.ReopenCrashGroupByIssue(ctx, s.Pool, event.Issue.Number) + return s.DB.WithContext(ctx). + Model(&models.CrashGroup{}). + Where("forgejo_issue_id = ?", event.Issue.Number). + Update("status", "open").Error } return nil diff --git a/internal/forgejo/webhooks_test.go b/internal/forgejo/webhooks_test.go new file mode 100644 index 0000000..a1497ab --- /dev/null +++ b/internal/forgejo/webhooks_test.go @@ -0,0 +1,97 @@ +package forgejo + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "strings" + "testing" +) + +func signBody(body, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifyAndParseWithForgejoHeaders(t *testing.T) { + secret := "supersecret" + body := `{"action":"closed","issue":{"number":7,"title":"[Cairn] crash"}}` + req := httptest.NewRequest("POST", "/webhooks/forgejo", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "issues") + req.Header.Set("X-Forgejo-Signature", signBody(body, secret)) + + event, eventType, err := VerifyAndParse(req, secret) + if err != nil { + t.Fatalf("VerifyAndParse returned error: %v", err) + } + if eventType != "issues" { + t.Fatalf("expected event type issues, got %q", eventType) + } + if event == nil || event.Issue == nil || event.Issue.Number != 7 { + t.Fatalf("unexpected parsed event: %#v", event) + } +} + +func TestVerifyAndParseWithGiteaFallbackHeaders(t *testing.T) { + secret := "fallbacksecret" + body := `{"action":"reopened","issue":{"number":42,"title":"[Cairn] crash"}}` + req := httptest.NewRequest("POST", "/webhooks/forgejo", strings.NewReader(body)) + req.Header.Set("X-Gitea-Event", "issues") + req.Header.Set("X-Gitea-Signature", signBody(body, secret)) + + event, eventType, err := VerifyAndParse(req, secret) + if err != nil { + t.Fatalf("VerifyAndParse returned error: %v", err) + } + if eventType != "issues" { + t.Fatalf("expected event type issues, got %q", eventType) + } + if event == nil || event.Issue == nil || event.Issue.Number != 42 { + t.Fatalf("unexpected parsed event: %#v", event) + } +} + +func TestVerifyAndParseRejectsBadSignature(t *testing.T) { + secret := "supersecret" + body := `{"action":"closed"}` + req := httptest.NewRequest("POST", "/webhooks/forgejo", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "issues") + req.Header.Set("X-Forgejo-Signature", "bad-signature") + + _, _, err := VerifyAndParse(req, secret) + if err == nil { + t.Fatal("expected error for bad signature, got nil") + } +} + +func TestVerifyAndParseWithoutSecretSkipsHMAC(t *testing.T) { + body := `{"action":"closed"}` + req := httptest.NewRequest("POST", "/webhooks/forgejo", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "issues") + + event, eventType, err := VerifyAndParse(req, "") + if err != nil { + t.Fatalf("VerifyAndParse returned error: %v", err) + } + if eventType != "issues" { + t.Fatalf("expected event type issues, got %q", eventType) + } + if event == nil || event.Action != "closed" { + t.Fatalf("unexpected parsed event: %#v", event) + } +} + +func TestVerifyHMAC(t *testing.T) { + body := []byte("payload") + secret := "abc123" + sig := signBody(string(body), secret) + + if !verifyHMAC(body, sig, secret) { + t.Fatal("expected verifyHMAC to accept valid signature") + } + if verifyHMAC(body, "invalid", secret) { + t.Fatal("expected verifyHMAC to reject invalid signature") + } +} diff --git a/internal/handler/campaigns.go b/internal/handler/campaigns.go index 2abf677..a68a235 100644 --- a/internal/handler/campaigns.go +++ b/internal/handler/campaigns.go @@ -5,12 +5,13 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) type CampaignHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB } func (h *CampaignHandler) List(c *gin.Context) { @@ -20,14 +21,20 @@ func (h *CampaignHandler) List(c *gin.Context) { limit = 50 } - campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.Pool, c.Query("repository_id"), limit, offset) + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if campaigns == nil { - campaigns = []models.Campaign{} + campaigns = []cairnapi.Campaign{} } c.JSON(http.StatusOK, gin.H{ @@ -39,9 +46,13 @@ func (h *CampaignHandler) List(c *gin.Context) { } func (h *CampaignHandler) Detail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "campaign id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - campaign, err := models.GetCampaign(c.Request.Context(), h.Pool, id) + campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "campaign not found"}) return @@ -66,13 +77,13 @@ func (h *CampaignHandler) Create(c *gin.Context) { ctx := c.Request.Context() - repo, err := models.GetOrCreateRepository(ctx, h.Pool, req.Owner, req.Repository) + repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - campaign, err := models.CreateCampaign(ctx, h.Pool, models.CreateCampaignParams{ + campaign, err := models.CreateCampaign(ctx, h.DB, models.CreateCampaignParams{ RepositoryID: repo.ID, Name: req.Name, Type: req.Type, @@ -86,8 +97,12 @@ func (h *CampaignHandler) Create(c *gin.Context) { } func (h *CampaignHandler) Finish(c *gin.Context) { - id := c.Param("id") - if err := models.FinishCampaign(c.Request.Context(), h.Pool, id); err != nil { + id, err := parseUintID(c.Param("id"), "campaign id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := models.FinishCampaign(c.Request.Context(), h.DB, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/crashgroups.go b/internal/handler/crashgroups.go index 7dabaea..4d0f564 100644 --- a/internal/handler/crashgroups.go +++ b/internal/handler/crashgroups.go @@ -5,19 +5,20 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) type CrashGroupHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB } type CrashGroupListResponse struct { - CrashGroups []models.CrashGroup `json:"crash_groups"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` + CrashGroups []cairnapi.CrashGroup `json:"crash_groups"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` } func (h *CrashGroupHandler) List(c *gin.Context) { @@ -27,9 +28,15 @@ func (h *CrashGroupHandler) List(c *gin.Context) { limit = 50 } + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + groups, total, err := models.ListCrashGroups( - c.Request.Context(), h.Pool, - c.Query("repository_id"), c.Query("status"), + c.Request.Context(), h.DB, + repoID, c.Query("status"), limit, offset, ) if err != nil { @@ -38,7 +45,7 @@ func (h *CrashGroupHandler) List(c *gin.Context) { } if groups == nil { - groups = []models.CrashGroup{} + groups = []cairnapi.CrashGroup{} } c.JSON(http.StatusOK, CrashGroupListResponse{ @@ -50,9 +57,13 @@ func (h *CrashGroupHandler) List(c *gin.Context) { } func (h *CrashGroupHandler) Detail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "crash group id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - group, err := models.GetCrashGroup(c.Request.Context(), h.Pool, id) + group, err := models.GetCrashGroup(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "crash group not found"}) return @@ -62,7 +73,7 @@ func (h *CrashGroupHandler) Detail(c *gin.Context) { } type SearchHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB } func (h *SearchHandler) Search(c *gin.Context) { @@ -78,14 +89,14 @@ func (h *SearchHandler) Search(c *gin.Context) { limit = 50 } - artifacts, total, err := models.SearchArtifacts(c.Request.Context(), h.Pool, q, limit, offset) + artifacts, total, err := models.SearchArtifacts(c.Request.Context(), h.DB, q, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if artifacts == nil { - artifacts = []models.Artifact{} + artifacts = []cairnapi.Artifact{} } c.JSON(http.StatusOK, gin.H{ diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 05069a8..760e6e8 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -5,31 +5,32 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) type DashboardHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB } type DashboardStats struct { - TotalArtifacts int `json:"total_artifacts"` - TotalRepos int `json:"total_repos"` - TotalCrashGroups int `json:"total_crash_groups"` - OpenCrashGroups int `json:"open_crash_groups"` - ActiveCampaigns int `json:"active_campaigns"` + TotalArtifacts int64 `json:"total_artifacts"` + TotalRepos int64 `json:"total_repos"` + TotalCrashGroups int64 `json:"total_crash_groups"` + OpenCrashGroups int64 `json:"open_crash_groups"` + ActiveCampaigns int64 `json:"active_campaigns"` } type TrendPoint struct { Date string `json:"date"` - Count int `json:"count"` + Count int64 `json:"count"` } type TopCrasher struct { Title string `json:"title"` - OccurrenceCount int `json:"occurrence_count"` + OccurrenceCount uint `json:"occurrence_count"` RepoName string `json:"repo_name"` - CrashGroupID string `json:"crash_group_id"` + CrashGroupID uint `json:"crash_group_id"` } type DashboardResponse struct { @@ -42,50 +43,50 @@ func (h *DashboardHandler) Stats(c *gin.Context) { ctx := c.Request.Context() var stats DashboardStats - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM artifacts").Scan(&stats.TotalArtifacts) - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM repositories").Scan(&stats.TotalRepos) - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&stats.TotalCrashGroups) - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&stats.OpenCrashGroups) - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM campaigns WHERE status = 'running'").Scan(&stats.ActiveCampaigns) + _ = h.DB.WithContext(ctx).Model(&models.Artifact{}).Count(&stats.TotalArtifacts).Error + _ = h.DB.WithContext(ctx).Model(&models.Repository{}).Count(&stats.TotalRepos).Error + _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Count(&stats.TotalCrashGroups).Error + _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Where("status = ?", "open").Count(&stats.OpenCrashGroups).Error + _ = h.DB.WithContext(ctx).Model(&models.Campaign{}).Where("status = ?", "running").Count(&stats.ActiveCampaigns).Error // Artifact trend for the last 30 days. var trend []TrendPoint - rows, err := h.Pool.Query(ctx, ` - SELECT DATE(created_at) as day, COUNT(*) - FROM artifacts - WHERE created_at >= $1 - GROUP BY day - ORDER BY day - `, time.Now().AddDate(0, 0, -30)) + type trendRow struct { + Day time.Time + Count int64 + } + var rows []trendRow + err := h.DB.WithContext(ctx). + Table("artifacts"). + Select("DATE(created_at) as day, COUNT(*) as count"). + Where("created_at >= ?", time.Now().AddDate(0, 0, -30)). + Group("day"). + Order("day"). + Scan(&rows).Error if err == nil { - defer rows.Close() - for rows.Next() { - var tp TrendPoint - var d time.Time - if rows.Scan(&d, &tp.Count) == nil { - tp.Date = d.Format("2006-01-02") - trend = append(trend, tp) - } + for _, row := range rows { + trend = append(trend, TrendPoint{Date: row.Day.Format("2006-01-02"), Count: row.Count}) } } // Top crashers (most frequent open crash groups). - var topCrashers []TopCrasher - rows2, err := h.Pool.Query(ctx, ` - SELECT cg.id, cg.title, cs.occurrence_count, r.name - FROM crash_groups cg - JOIN crash_signatures cs ON cs.id = cg.crash_signature_id - JOIN repositories r ON r.id = cg.repository_id - WHERE cg.status = 'open' - ORDER BY cs.occurrence_count DESC - LIMIT 10 - `) + var groups []models.CrashGroup + err = h.DB.WithContext(ctx).Where("status = ?", "open").Order("last_seen_at DESC").Limit(50).Find(&groups).Error + topCrashers := make([]TopCrasher, 0, 10) if err == nil { - defer rows2.Close() - for rows2.Next() { - var tc TopCrasher - if rows2.Scan(&tc.CrashGroupID, &tc.Title, &tc.OccurrenceCount, &tc.RepoName) == nil { - topCrashers = append(topCrashers, tc) + for _, group := range groups { + fullGroup, fullErr := models.GetCrashGroup(ctx, h.DB, group.ID) + if fullErr != nil { + continue + } + topCrashers = append(topCrashers, TopCrasher{ + CrashGroupID: fullGroup.ID, + Title: fullGroup.Title, + OccurrenceCount: fullGroup.OccurrenceCount, + RepoName: fullGroup.RepoName, + }) + if len(topCrashers) == 10 { + break } } } diff --git a/internal/handler/idparse.go b/internal/handler/idparse.go new file mode 100644 index 0000000..dd9788a --- /dev/null +++ b/internal/handler/idparse.go @@ -0,0 +1,25 @@ +package handler + +import ( + "fmt" + "strconv" +) + +func parseUintID(raw string, field string) (uint, error) { + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s", field) + } + return uint(id), nil +} + +func parseOptionalUintID(raw string, field string) (*uint, error) { + if raw == "" { + return nil, nil + } + id, err := parseUintID(raw, field) + if err != nil { + return nil, err + } + return &id, nil +} diff --git a/internal/handler/idparse_test.go b/internal/handler/idparse_test.go new file mode 100644 index 0000000..b04f74a --- /dev/null +++ b/internal/handler/idparse_test.go @@ -0,0 +1,38 @@ +package handler + +import "testing" + +func TestParseUintID(t *testing.T) { + id, err := parseUintID("123", "artifact id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 123 { + t.Fatalf("expected 123, got %d", id) + } +} + +func TestParseUintIDInvalid(t *testing.T) { + _, err := parseUintID("abc", "artifact id") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestParseOptionalUintID(t *testing.T) { + id, err := parseOptionalUintID("", "repository_id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != nil { + t.Fatalf("expected nil id for empty input, got %v", *id) + } + + id, err = parseOptionalUintID("9", "repository_id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id == nil || *id != 9 { + t.Fatalf("expected 9, got %#v", id) + } +} diff --git a/internal/handler/ingest.go b/internal/handler/ingest.go index 7315747..490aec4 100644 --- a/internal/handler/ingest.go +++ b/internal/handler/ingest.go @@ -5,17 +5,18 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "github.com/mattnite/cairn/internal/blob" "github.com/mattnite/cairn/internal/fingerprint" "github.com/mattnite/cairn/internal/forgejo" "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) type IngestHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB Store blob.Store ForgejoSync *forgejo.Sync } @@ -58,19 +59,23 @@ func (h *IngestHandler) Create(c *gin.Context) { ctx := c.Request.Context() - repo, err := models.GetOrCreateRepository(ctx, h.Pool, req.Owner, req.Repository) + repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - commit, err := models.GetOrCreateCommit(ctx, h.Pool, repo.ID, req.CommitSHA) - if err != nil { + commit := &models.Commit{RepositoryID: repo.ID, SHA: req.CommitSHA} + if err := h.DB.WithContext(ctx).Where("repository_id = ? AND sha = ?", repo.ID, req.CommitSHA).FirstOrCreate(commit).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - blobKey := fmt.Sprintf("%s/%s/%s/%s", repo.Name, commit.SHA[:8], req.Type, header.Filename) + shortSHA := commit.SHA + if len(shortSHA) > 8 { + shortSHA = shortSHA[:8] + } + blobKey := fmt.Sprintf("%s/%s/%s/%s", repo.Name, shortSHA, req.Type, header.Filename) if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()}) @@ -85,7 +90,7 @@ func (h *IngestHandler) Create(c *gin.Context) { stackTrace = &req.StackTrace } - artifact, err := models.CreateArtifact(ctx, h.Pool, models.CreateArtifactParams{ + artifact, err := models.CreateArtifact(ctx, h.DB, models.CreateArtifactParams{ RepositoryID: repo.ID, CommitID: commit.ID, Type: req.Type, @@ -104,16 +109,19 @@ func (h *IngestHandler) Create(c *gin.Context) { // Run fingerprinting pipeline if we have a stack trace. if req.StackTrace != "" { if result := fingerprint.Compute(req.StackTrace); result != nil { - sig, isNew, err := models.GetOrCreateSignature(ctx, h.Pool, repo.ID, result.Fingerprint, stackTrace) + sig, isNew, err := models.GetOrCreateSignature(ctx, h.DB, repo.ID, result.Fingerprint, stackTrace) if err == nil { - _ = models.UpdateArtifactSignature(ctx, h.Pool, artifact.ID, sig.ID, result.Fingerprint) + _ = h.DB.WithContext(ctx). + Model(&models.Artifact{}). + Where("id = ?", artifact.ID). + Update("crash_signature_id", sig.ID).Error if isNew { title := req.Type + " crash in " + req.Repository - if len(result.Frames) > 0 { + if len(result.Frames) > 0 && strings.TrimSpace(result.Frames[0].Function) != "" { title = req.Type + ": " + result.Frames[0].Function } - group, groupErr := models.CreateCrashGroup(ctx, h.Pool, sig.ID, repo.ID, title) + group, groupErr := models.CreateCrashGroup(ctx, h.DB, sig.ID, repo.ID, title) if groupErr == nil && h.ForgejoSync != nil { _ = h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace) } @@ -126,14 +134,18 @@ func (h *IngestHandler) Create(c *gin.Context) { } type DownloadHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB Store blob.Store } func (h *DownloadHandler) Download(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "artifact id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - artifact, err := models.GetArtifact(c.Request.Context(), h.Pool, id) + artifact, err := models.GetArtifact(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "artifact not found"}) return diff --git a/internal/handler/regression.go b/internal/handler/regression.go index ed121b8..2b8ddf1 100644 --- a/internal/handler/regression.go +++ b/internal/handler/regression.go @@ -5,14 +5,14 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "github.com/mattnite/cairn/internal/forgejo" "github.com/mattnite/cairn/internal/models" "github.com/mattnite/cairn/internal/regression" + "gorm.io/gorm" ) type RegressionHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB ForgejoSync *forgejo.Sync } @@ -31,13 +31,13 @@ func (h *RegressionHandler) Check(c *gin.Context) { ctx := c.Request.Context() - repo, err := models.GetRepositoryByName(ctx, h.Pool, req.Repository) + repo, err := models.GetRepositoryByName(ctx, h.DB, req.Repository) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) return } - result, err := regression.Compare(ctx, h.Pool, repo.ID, req.BaseSHA, req.HeadSHA) + result, err := regression.Compare(ctx, h.DB, repo.ID, req.BaseSHA, req.HeadSHA) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/search.go b/internal/handler/search.go index 04bd842..aea0e1e 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -5,19 +5,20 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" "github.com/mattnite/cairn/internal/models" + "gorm.io/gorm" ) type ArtifactHandler struct { - Pool *pgxpool.Pool + DB *gorm.DB } type ArtifactListResponse struct { - Artifacts []models.Artifact `json:"artifacts"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` + Artifacts []cairnapi.Artifact `json:"artifacts"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` } func (h *ArtifactHandler) List(c *gin.Context) { @@ -27,8 +28,14 @@ func (h *ArtifactHandler) List(c *gin.Context) { limit = 50 } - artifacts, total, err := models.ListArtifacts(c.Request.Context(), h.Pool, models.ListArtifactsParams{ - RepositoryID: c.Query("repository_id"), + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + artifacts, total, err := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{ + RepositoryID: repoID, CommitSHA: c.Query("commit_sha"), Type: c.Query("type"), Limit: limit, @@ -40,7 +47,7 @@ func (h *ArtifactHandler) List(c *gin.Context) { } if artifacts == nil { - artifacts = []models.Artifact{} + artifacts = []cairnapi.Artifact{} } c.JSON(http.StatusOK, ArtifactListResponse{ @@ -52,9 +59,13 @@ func (h *ArtifactHandler) List(c *gin.Context) { } func (h *ArtifactHandler) Detail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "artifact id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - artifact, err := models.GetArtifact(c.Request.Context(), h.Pool, id) + artifact, err := models.GetArtifact(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "artifact not found"}) return diff --git a/internal/models/artifact.go b/internal/models/artifact.go index c43e865..0583efe 100644 --- a/internal/models/artifact.go +++ b/internal/models/artifact.go @@ -5,13 +5,15 @@ import ( "encoding/json" "fmt" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" + "gorm.io/gorm" ) type CreateArtifactParams struct { - RepositoryID string - CommitID string - BuildID *string + RepositoryID uint + CommitID uint + BuildID *uint + CampaignID *uint Type string BlobKey string BlobSize int64 @@ -21,7 +23,7 @@ type CreateArtifactParams struct { Metadata json.RawMessage } -func CreateArtifact(ctx context.Context, pool *pgxpool.Pool, p CreateArtifactParams) (*Artifact, error) { +func CreateArtifact(ctx context.Context, db *gorm.DB, p CreateArtifactParams) (*cairnapi.Artifact, error) { if p.Tags == nil { p.Tags = json.RawMessage("{}") } @@ -29,125 +31,151 @@ func CreateArtifact(ctx context.Context, pool *pgxpool.Pool, p CreateArtifactPar p.Metadata = json.RawMessage("{}") } - a := &Artifact{} - err := pool.QueryRow(ctx, ` - INSERT INTO artifacts (repository_id, commit_id, build_id, type, blob_key, blob_size, crash_message, stack_trace, tags, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id, repository_id, commit_id, build_id, type, blob_key, blob_size, crash_message, stack_trace, tags, metadata, created_at - `, p.RepositoryID, p.CommitID, p.BuildID, p.Type, p.BlobKey, p.BlobSize, p.CrashMessage, p.StackTrace, p.Tags, p.Metadata).Scan( - &a.ID, &a.RepositoryID, &a.CommitID, &a.BuildID, &a.Type, &a.BlobKey, &a.BlobSize, - &a.CrashMessage, &a.StackTrace, &a.Tags, &a.Metadata, &a.CreatedAt, - ) - if err != nil { + a := &Artifact{ + RepositoryID: p.RepositoryID, + CommitID: p.CommitID, + BuildID: p.BuildID, + CampaignID: p.CampaignID, + Type: p.Type, + BlobKey: p.BlobKey, + BlobSize: p.BlobSize, + CrashMessage: p.CrashMessage, + StackTrace: p.StackTrace, + Tags: p.Tags, + Metadata: p.Metadata, + } + + if err := db.WithContext(ctx).Create(a).Error; err != nil { return nil, fmt.Errorf("creating artifact: %w", err) } - return a, nil + return enrichArtifact(ctx, db, *a) } -func GetArtifact(ctx context.Context, pool *pgxpool.Pool, id string) (*Artifact, error) { +func GetArtifact(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Artifact, error) { a := &Artifact{} - err := pool.QueryRow(ctx, ` - SELECT a.id, a.repository_id, a.commit_id, a.build_id, a.type, a.blob_key, a.blob_size, - a.crash_message, a.stack_trace, a.tags, a.metadata, a.created_at, - r.name, c.sha - FROM artifacts a - JOIN repositories r ON r.id = a.repository_id - JOIN commits c ON c.id = a.commit_id - WHERE a.id = $1 - `, id).Scan( - &a.ID, &a.RepositoryID, &a.CommitID, &a.BuildID, &a.Type, &a.BlobKey, &a.BlobSize, - &a.CrashMessage, &a.StackTrace, &a.Tags, &a.Metadata, &a.CreatedAt, - &a.RepoName, &a.CommitSHA, - ) - if err != nil { + if err := db.WithContext(ctx).First(a, id).Error; err != nil { return nil, fmt.Errorf("getting artifact: %w", err) } - return a, nil + return enrichArtifact(ctx, db, *a) } type ListArtifactsParams struct { - RepositoryID string + RepositoryID *uint CommitSHA string Type string - SignatureID string - CampaignID string + SignatureID *uint + CampaignID *uint Limit int Offset int } -func ListArtifacts(ctx context.Context, pool *pgxpool.Pool, p ListArtifactsParams) ([]Artifact, int, error) { +func ListArtifacts(ctx context.Context, db *gorm.DB, p ListArtifactsParams) ([]cairnapi.Artifact, int64, error) { if p.Limit <= 0 { p.Limit = 50 } - baseQuery := ` - FROM artifacts a - JOIN repositories r ON r.id = a.repository_id - JOIN commits c ON c.id = a.commit_id - WHERE 1=1 - ` - args := []any{} - argN := 1 + query := db.WithContext(ctx).Model(&Artifact{}) - if p.RepositoryID != "" { - baseQuery += fmt.Sprintf(" AND a.repository_id = $%d", argN) - args = append(args, p.RepositoryID) - argN++ - } - if p.CommitSHA != "" { - baseQuery += fmt.Sprintf(" AND c.sha = $%d", argN) - args = append(args, p.CommitSHA) - argN++ + if p.RepositoryID != nil { + query = query.Where("repository_id = ?", *p.RepositoryID) } if p.Type != "" { - baseQuery += fmt.Sprintf(" AND a.type = $%d", argN) - args = append(args, p.Type) - argN++ + query = query.Where("type = ?", p.Type) } - if p.SignatureID != "" { - baseQuery += fmt.Sprintf(" AND a.signature_id = $%d", argN) - args = append(args, p.SignatureID) - argN++ + if p.SignatureID != nil { + query = query.Where("crash_signature_id = ?", *p.SignatureID) } - if p.CampaignID != "" { - baseQuery += fmt.Sprintf(" AND a.campaign_id = $%d", argN) - args = append(args, p.CampaignID) - argN++ + if p.CampaignID != nil { + query = query.Where("campaign_id = ?", *p.CampaignID) + } + if p.CommitSHA != "" { + query = query.Joins("JOIN commits ON commits.id = artifacts.commit_id").Where("commits.sha = ?", p.CommitSHA) } - var total int - err := pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQuery, args...).Scan(&total) - if err != nil { + var total int64 + if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting artifacts: %w", err) } - selectQuery := fmt.Sprintf(` - SELECT a.id, a.repository_id, a.commit_id, a.build_id, a.type, a.blob_key, a.blob_size, - a.crash_message, a.stack_trace, a.tags, a.metadata, a.created_at, - r.name, c.sha - %s - ORDER BY a.created_at DESC - LIMIT $%d OFFSET $%d - `, baseQuery, argN, argN+1) - args = append(args, p.Limit, p.Offset) - - rows, err := pool.Query(ctx, selectQuery, args...) - if err != nil { + var dbArtifacts []Artifact + if err := query.Order("created_at DESC").Limit(p.Limit).Offset(p.Offset).Find(&dbArtifacts).Error; err != nil { return nil, 0, fmt.Errorf("listing artifacts: %w", err) } - defer rows.Close() - var artifacts []Artifact - for rows.Next() { - var a Artifact - if err := rows.Scan( - &a.ID, &a.RepositoryID, &a.CommitID, &a.BuildID, &a.Type, &a.BlobKey, &a.BlobSize, - &a.CrashMessage, &a.StackTrace, &a.Tags, &a.Metadata, &a.CreatedAt, - &a.RepoName, &a.CommitSHA, - ); err != nil { - return nil, 0, fmt.Errorf("scanning artifact: %w", err) + artifacts := make([]cairnapi.Artifact, 0, len(dbArtifacts)) + for _, m := range dbArtifacts { + a, err := enrichArtifact(ctx, db, m) + if err != nil { + return nil, 0, err } - artifacts = append(artifacts, a) + artifacts = append(artifacts, *a) } + return artifacts, total, nil } + +func SearchArtifacts(ctx context.Context, db *gorm.DB, query string, limit, offset int) ([]cairnapi.Artifact, int64, error) { + if limit <= 0 { + limit = 50 + } + + q := db.WithContext(ctx).Model(&Artifact{}).Where( + "type ILIKE ? OR crash_message ILIKE ? OR stack_trace ILIKE ?", + "%"+query+"%", "%"+query+"%", "%"+query+"%", + ) + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting search results: %w", err) + } + + var dbArtifacts []Artifact + if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbArtifacts).Error; err != nil { + return nil, 0, fmt.Errorf("searching artifacts: %w", err) + } + + artifacts := make([]cairnapi.Artifact, 0, len(dbArtifacts)) + for _, m := range dbArtifacts { + a, err := enrichArtifact(ctx, db, m) + if err != nil { + return nil, 0, err + } + artifacts = append(artifacts, *a) + } + + return artifacts, total, nil +} + +func enrichArtifact(ctx context.Context, db *gorm.DB, model Artifact) (*cairnapi.Artifact, error) { + repo := &Repository{} + if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil { + return nil, fmt.Errorf("loading artifact repository: %w", err) + } + commit := &Commit{} + if err := db.WithContext(ctx).First(commit, model.CommitID).Error; err != nil { + return nil, fmt.Errorf("loading artifact commit: %w", err) + } + artifact := artifactFromModel(model) + artifact.RepoName = repo.Name + artifact.CommitSHA = commit.SHA + return &artifact, nil +} + +func artifactFromModel(m Artifact) cairnapi.Artifact { + return cairnapi.Artifact{ + ID: m.ID, + RepositoryID: m.RepositoryID, + CommitID: m.CommitID, + BuildID: m.BuildID, + CampaignID: m.CampaignID, + CrashSignatureID: m.CrashSignatureID, + Type: m.Type, + BlobKey: m.BlobKey, + BlobSize: m.BlobSize, + CrashMessage: m.CrashMessage, + StackTrace: m.StackTrace, + Tags: m.Tags, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt, + } +} diff --git a/internal/models/campaign.go b/internal/models/campaign.go index ac4edd4..bbf5a9a 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -6,35 +6,19 @@ import ( "fmt" "time" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" + "gorm.io/gorm" ) -type Campaign struct { - ID string `json:"id"` - RepositoryID string `json:"repository_id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - Tags json.RawMessage `json:"tags,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - - // Joined fields. - RepoName string `json:"repo_name,omitempty"` - ArtifactCount int `json:"artifact_count,omitempty"` -} - type CreateCampaignParams struct { - RepositoryID string + RepositoryID uint Name string Type string Tags json.RawMessage Metadata json.RawMessage } -func CreateCampaign(ctx context.Context, pool *pgxpool.Pool, p CreateCampaignParams) (*Campaign, error) { +func CreateCampaign(ctx context.Context, db *gorm.DB, p CreateCampaignParams) (*cairnapi.Campaign, error) { if p.Tags == nil { p.Tags = json.RawMessage("{}") } @@ -42,105 +26,100 @@ func CreateCampaign(ctx context.Context, pool *pgxpool.Pool, p CreateCampaignPar p.Metadata = json.RawMessage("{}") } - c := &Campaign{} - err := pool.QueryRow(ctx, ` - INSERT INTO campaigns (repository_id, name, type, tags, metadata) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, repository_id, name, type, status, started_at, finished_at, tags, metadata, created_at - `, p.RepositoryID, p.Name, p.Type, p.Tags, p.Metadata).Scan( - &c.ID, &c.RepositoryID, &c.Name, &c.Type, &c.Status, - &c.StartedAt, &c.FinishedAt, &c.Tags, &c.Metadata, &c.CreatedAt, - ) - if err != nil { + campaign := &Campaign{ + RepositoryID: p.RepositoryID, + Name: p.Name, + Type: p.Type, + Status: "running", + StartedAt: time.Now(), + Tags: p.Tags, + Metadata: p.Metadata, + } + + if err := db.WithContext(ctx).Create(campaign).Error; err != nil { return nil, fmt.Errorf("creating campaign: %w", err) } - return c, nil + return enrichCampaign(ctx, db, *campaign) } -func FinishCampaign(ctx context.Context, pool *pgxpool.Pool, id string) error { - _, err := pool.Exec(ctx, ` - UPDATE campaigns SET status = 'finished', finished_at = NOW() WHERE id = $1 - `, id) - if err != nil { +func FinishCampaign(ctx context.Context, db *gorm.DB, id uint) error { + now := time.Now() + if err := db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(map[string]any{ + "status": "finished", + "finished_at": now, + }).Error; err != nil { return fmt.Errorf("finishing campaign: %w", err) } return nil } -func GetCampaign(ctx context.Context, pool *pgxpool.Pool, id string) (*Campaign, error) { - c := &Campaign{} - err := pool.QueryRow(ctx, ` - SELECT c.id, c.repository_id, c.name, c.type, c.status, c.started_at, c.finished_at, - c.tags, c.metadata, c.created_at, - r.name, - (SELECT COUNT(*) FROM artifacts a WHERE a.campaign_id = c.id) - FROM campaigns c - JOIN repositories r ON r.id = c.repository_id - WHERE c.id = $1 - `, id).Scan( - &c.ID, &c.RepositoryID, &c.Name, &c.Type, &c.Status, - &c.StartedAt, &c.FinishedAt, &c.Tags, &c.Metadata, &c.CreatedAt, - &c.RepoName, &c.ArtifactCount, - ) - if err != nil { +func GetCampaign(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Campaign, error) { + campaign := &Campaign{} + if err := db.WithContext(ctx).First(campaign, id).Error; err != nil { return nil, fmt.Errorf("getting campaign: %w", err) } - return c, nil + return enrichCampaign(ctx, db, *campaign) } -func ListCampaigns(ctx context.Context, pool *pgxpool.Pool, repoID string, limit, offset int) ([]Campaign, int, error) { +func ListCampaigns(ctx context.Context, db *gorm.DB, repoID *uint, limit, offset int) ([]cairnapi.Campaign, int64, error) { if limit <= 0 { limit = 50 } - baseQuery := ` - FROM campaigns c - JOIN repositories r ON r.id = c.repository_id - WHERE 1=1 - ` - args := []any{} - argN := 1 - - if repoID != "" { - baseQuery += fmt.Sprintf(" AND c.repository_id = $%d", argN) - args = append(args, repoID) - argN++ + query := db.WithContext(ctx).Model(&Campaign{}) + if repoID != nil { + query = query.Where("repository_id = ?", *repoID) } - var total int - err := pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQuery, args...).Scan(&total) - if err != nil { + var total int64 + if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting campaigns: %w", err) } - selectQuery := fmt.Sprintf(` - SELECT c.id, c.repository_id, c.name, c.type, c.status, c.started_at, c.finished_at, - c.tags, c.metadata, c.created_at, - r.name, - (SELECT COUNT(*) FROM artifacts a WHERE a.campaign_id = c.id) - %s - ORDER BY c.created_at DESC - LIMIT $%d OFFSET $%d - `, baseQuery, argN, argN+1) - args = append(args, limit, offset) - - rows, err := pool.Query(ctx, selectQuery, args...) - if err != nil { + var dbCampaigns []Campaign + if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbCampaigns).Error; err != nil { return nil, 0, fmt.Errorf("listing campaigns: %w", err) } - defer rows.Close() - var campaigns []Campaign - for rows.Next() { - var c Campaign - if err := rows.Scan( - &c.ID, &c.RepositoryID, &c.Name, &c.Type, &c.Status, - &c.StartedAt, &c.FinishedAt, &c.Tags, &c.Metadata, &c.CreatedAt, - &c.RepoName, &c.ArtifactCount, - ); err != nil { - return nil, 0, fmt.Errorf("scanning campaign: %w", err) + campaigns := make([]cairnapi.Campaign, 0, len(dbCampaigns)) + for _, m := range dbCampaigns { + c, err := enrichCampaign(ctx, db, m) + if err != nil { + return nil, 0, err } - campaigns = append(campaigns, c) + campaigns = append(campaigns, *c) } + return campaigns, total, nil } + +func enrichCampaign(ctx context.Context, db *gorm.DB, model Campaign) (*cairnapi.Campaign, error) { + repo := &Repository{} + if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil { + return nil, fmt.Errorf("loading campaign repository: %w", err) + } + + var count int64 + if err := db.WithContext(ctx).Model(&Artifact{}).Where("campaign_id = ?", model.ID).Count(&count).Error; err != nil { + return nil, fmt.Errorf("counting campaign artifacts: %w", err) + } + campaign := campaignFromModel(model) + campaign.RepoName = repo.Name + campaign.ArtifactCount = count + return &campaign, nil +} + +func campaignFromModel(m Campaign) cairnapi.Campaign { + return cairnapi.Campaign{ + ID: m.ID, + RepositoryID: m.RepositoryID, + Name: m.Name, + Type: m.Type, + Status: m.Status, + StartedAt: m.StartedAt, + FinishedAt: m.FinishedAt, + Tags: m.Tags, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt, + } +} diff --git a/internal/models/commit.go b/internal/models/commit.go deleted file mode 100644 index 56de788..0000000 --- a/internal/models/commit.go +++ /dev/null @@ -1,22 +0,0 @@ -package models - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" -) - -func GetOrCreateCommit(ctx context.Context, pool *pgxpool.Pool, repositoryID, sha string) (*Commit, error) { - c := &Commit{} - err := pool.QueryRow(ctx, ` - INSERT INTO commits (repository_id, sha) - VALUES ($1, $2) - ON CONFLICT (repository_id, sha) DO UPDATE SET repository_id = EXCLUDED.repository_id - RETURNING id, repository_id, sha, author, message, branch, committed_at, created_at - `, repositoryID, sha).Scan(&c.ID, &c.RepositoryID, &c.SHA, &c.Author, &c.Message, &c.Branch, &c.CommittedAt, &c.CreatedAt) - if err != nil { - return nil, fmt.Errorf("get or create commit: %w", err) - } - return c, nil -} diff --git a/internal/models/crash_group.go b/internal/models/crash_group.go index 500c0ca..f1d685b 100644 --- a/internal/models/crash_group.go +++ b/internal/models/crash_group.go @@ -5,260 +5,144 @@ import ( "fmt" "time" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" + "gorm.io/gorm" ) -type CrashSignature struct { - ID string `json:"id"` - RepositoryID string `json:"repository_id"` - Fingerprint string `json:"fingerprint"` - SampleTrace *string `json:"sample_trace,omitempty"` - FirstSeenAt time.Time `json:"first_seen_at"` - LastSeenAt time.Time `json:"last_seen_at"` - OccurrenceCount int `json:"occurrence_count"` -} - -type CrashGroup struct { - ID string `json:"id"` - CrashSignatureID string `json:"crash_signature_id"` - RepositoryID string `json:"repository_id"` - Title string `json:"title"` - Status string `json:"status"` - ForgejoIssueID *int `json:"forgejo_issue_id,omitempty"` - ForgejoIssueURL *string `json:"forgejo_issue_url,omitempty"` - FirstSeenAt time.Time `json:"first_seen_at"` - LastSeenAt time.Time `json:"last_seen_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Joined fields - RepoName string `json:"repo_name,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - OccurrenceCount int `json:"occurrence_count,omitempty"` -} - -// GetOrCreateSignature upserts a crash signature, incrementing occurrence count. -func GetOrCreateSignature(ctx context.Context, pool *pgxpool.Pool, repoID, fingerprint string, sampleTrace *string) (*CrashSignature, bool, error) { +func GetOrCreateSignature(ctx context.Context, db *gorm.DB, repoID uint, fingerprint string, sampleTrace *string) (*cairnapi.CrashSignature, bool, error) { sig := &CrashSignature{} - var created bool - - // Try insert first. - err := pool.QueryRow(ctx, ` - INSERT INTO crash_signatures (repository_id, fingerprint, sample_trace) - VALUES ($1, $2, $3) - ON CONFLICT (repository_id, fingerprint) - DO UPDATE SET - last_seen_at = NOW(), - occurrence_count = crash_signatures.occurrence_count + 1 - RETURNING id, repository_id, fingerprint, sample_trace, first_seen_at, last_seen_at, occurrence_count - `, repoID, fingerprint, sampleTrace).Scan( - &sig.ID, &sig.RepositoryID, &sig.Fingerprint, &sig.SampleTrace, - &sig.FirstSeenAt, &sig.LastSeenAt, &sig.OccurrenceCount, - ) - if err != nil { - return nil, false, fmt.Errorf("get or create signature: %w", err) + err := db.WithContext(ctx).Where("repository_id = ? AND fingerprint = ?", repoID, fingerprint).First(sig).Error + if err == nil { + sig.LastSeenAt = time.Now() + sig.OccurrenceCount++ + if sig.SampleTrace == nil && sampleTrace != nil { + sig.SampleTrace = sampleTrace + } + if saveErr := db.WithContext(ctx).Save(sig).Error; saveErr != nil { + return nil, false, fmt.Errorf("updating signature: %w", saveErr) + } + out := crashSignatureFromModel(*sig) + return &out, false, nil + } + if err != gorm.ErrRecordNotFound { + return nil, false, fmt.Errorf("querying signature: %w", err) } - // If occurrence count is 1, this is a new signature. - created = sig.OccurrenceCount == 1 - return sig, created, nil + now := time.Now() + sig = &CrashSignature{ + RepositoryID: repoID, + Fingerprint: fingerprint, + SampleTrace: sampleTrace, + FirstSeenAt: now, + LastSeenAt: now, + OccurrenceCount: 1, + } + if err := db.WithContext(ctx).Create(sig).Error; err != nil { + return nil, false, fmt.Errorf("creating signature: %w", err) + } + out := crashSignatureFromModel(*sig) + return &out, true, nil } -// CreateCrashGroup creates a crash group for a new signature. -func CreateCrashGroup(ctx context.Context, pool *pgxpool.Pool, sigID, repoID, title string) (*CrashGroup, error) { - cg := &CrashGroup{} - err := pool.QueryRow(ctx, ` - INSERT INTO crash_groups (crash_signature_id, repository_id, title) - VALUES ($1, $2, $3) - RETURNING id, crash_signature_id, repository_id, title, status, - forgejo_issue_id, forgejo_issue_url, first_seen_at, last_seen_at, created_at, updated_at - `, sigID, repoID, title).Scan( - &cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status, - &cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt, - &cg.CreatedAt, &cg.UpdatedAt, - ) - if err != nil { +func CreateCrashGroup(ctx context.Context, db *gorm.DB, sigID, repoID uint, title string) (*cairnapi.CrashGroup, error) { + now := time.Now() + group := &CrashGroup{ + CrashSignatureID: sigID, + RepositoryID: repoID, + Title: title, + Status: "open", + FirstSeenAt: now, + LastSeenAt: now, + } + if err := db.WithContext(ctx).Create(group).Error; err != nil { return nil, fmt.Errorf("creating crash group: %w", err) } - return cg, nil + return enrichCrashGroup(ctx, db, *group) } -// ListCrashGroups returns crash groups with joined data. -func ListCrashGroups(ctx context.Context, pool *pgxpool.Pool, repoID, status string, limit, offset int) ([]CrashGroup, int, error) { +func ListCrashGroups(ctx context.Context, db *gorm.DB, repoID *uint, status string, limit, offset int) ([]cairnapi.CrashGroup, int64, error) { if limit <= 0 { limit = 50 } - baseQuery := ` - FROM crash_groups cg - JOIN crash_signatures cs ON cs.id = cg.crash_signature_id - JOIN repositories r ON r.id = cg.repository_id - WHERE 1=1 - ` - args := []any{} - argN := 1 - - if repoID != "" { - baseQuery += fmt.Sprintf(" AND cg.repository_id = $%d", argN) - args = append(args, repoID) - argN++ + query := db.WithContext(ctx).Model(&CrashGroup{}) + if repoID != nil { + query = query.Where("repository_id = ?", *repoID) } if status != "" { - baseQuery += fmt.Sprintf(" AND cg.status = $%d", argN) - args = append(args, status) - argN++ + query = query.Where("status = ?", status) } - var total int - err := pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQuery, args...).Scan(&total) - if err != nil { + var total int64 + if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting crash groups: %w", err) } - selectQuery := fmt.Sprintf(` - SELECT cg.id, cg.crash_signature_id, cg.repository_id, cg.title, cg.status, - cg.forgejo_issue_id, cg.forgejo_issue_url, cg.first_seen_at, cg.last_seen_at, - cg.created_at, cg.updated_at, - r.name, cs.fingerprint, cs.occurrence_count - %s - ORDER BY cg.last_seen_at DESC - LIMIT $%d OFFSET $%d - `, baseQuery, argN, argN+1) - args = append(args, limit, offset) - - rows, err := pool.Query(ctx, selectQuery, args...) - if err != nil { + var dbGroups []CrashGroup + if err := query.Order("last_seen_at DESC").Limit(limit).Offset(offset).Find(&dbGroups).Error; err != nil { return nil, 0, fmt.Errorf("listing crash groups: %w", err) } - defer rows.Close() - var groups []CrashGroup - for rows.Next() { - var cg CrashGroup - if err := rows.Scan( - &cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status, - &cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt, - &cg.CreatedAt, &cg.UpdatedAt, - &cg.RepoName, &cg.Fingerprint, &cg.OccurrenceCount, - ); err != nil { - return nil, 0, fmt.Errorf("scanning crash group: %w", err) + groups := make([]cairnapi.CrashGroup, 0, len(dbGroups)) + for _, m := range dbGroups { + g, err := enrichCrashGroup(ctx, db, m) + if err != nil { + return nil, 0, err } - groups = append(groups, cg) + groups = append(groups, *g) } + return groups, total, nil } -// GetCrashGroup returns a single crash group by ID. -func GetCrashGroup(ctx context.Context, pool *pgxpool.Pool, id string) (*CrashGroup, error) { - cg := &CrashGroup{} - err := pool.QueryRow(ctx, ` - SELECT cg.id, cg.crash_signature_id, cg.repository_id, cg.title, cg.status, - cg.forgejo_issue_id, cg.forgejo_issue_url, cg.first_seen_at, cg.last_seen_at, - cg.created_at, cg.updated_at, - r.name, cs.fingerprint, cs.occurrence_count - FROM crash_groups cg - JOIN crash_signatures cs ON cs.id = cg.crash_signature_id - JOIN repositories r ON r.id = cg.repository_id - WHERE cg.id = $1 - `, id).Scan( - &cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status, - &cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt, - &cg.CreatedAt, &cg.UpdatedAt, - &cg.RepoName, &cg.Fingerprint, &cg.OccurrenceCount, - ) - if err != nil { +func GetCrashGroup(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.CrashGroup, error) { + group := &CrashGroup{} + if err := db.WithContext(ctx).First(group, id).Error; err != nil { return nil, fmt.Errorf("getting crash group: %w", err) } - return cg, nil + return enrichCrashGroup(ctx, db, *group) } -// UpdateCrashGroupIssue links a crash group to a Forgejo issue. -func UpdateCrashGroupIssue(ctx context.Context, pool *pgxpool.Pool, groupID string, issueNumber int, issueURL string) error { - _, err := pool.Exec(ctx, ` - UPDATE crash_groups SET forgejo_issue_id = $1, forgejo_issue_url = $2, updated_at = NOW() WHERE id = $3 - `, issueNumber, issueURL, groupID) - if err != nil { - return fmt.Errorf("updating crash group issue: %w", err) +func enrichCrashGroup(ctx context.Context, db *gorm.DB, model CrashGroup) (*cairnapi.CrashGroup, error) { + repo := &Repository{} + if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil { + return nil, fmt.Errorf("loading crash group repository: %w", err) } - return nil + sig := &CrashSignature{} + if err := db.WithContext(ctx).First(sig, model.CrashSignatureID).Error; err != nil { + return nil, fmt.Errorf("loading crash signature: %w", err) + } + + group := crashGroupFromModel(model) + group.RepoName = repo.Name + group.Fingerprint = sig.Fingerprint + group.OccurrenceCount = sig.OccurrenceCount + return &group, nil } -// ResolveCrashGroupByIssue marks a crash group as resolved when its Forgejo issue is closed. -func ResolveCrashGroupByIssue(ctx context.Context, pool *pgxpool.Pool, issueNumber int) error { - _, err := pool.Exec(ctx, ` - UPDATE crash_groups SET status = 'resolved', updated_at = NOW() WHERE forgejo_issue_id = $1 - `, issueNumber) - if err != nil { - return fmt.Errorf("resolving crash group by issue: %w", err) +func crashSignatureFromModel(m CrashSignature) cairnapi.CrashSignature { + return cairnapi.CrashSignature{ + ID: m.ID, + RepositoryID: m.RepositoryID, + Fingerprint: m.Fingerprint, + SampleTrace: m.SampleTrace, + FirstSeenAt: m.FirstSeenAt, + LastSeenAt: m.LastSeenAt, + OccurrenceCount: m.OccurrenceCount, } - return nil } -// ReopenCrashGroupByIssue reopens a crash group when its Forgejo issue is reopened. -func ReopenCrashGroupByIssue(ctx context.Context, pool *pgxpool.Pool, issueNumber int) error { - _, err := pool.Exec(ctx, ` - UPDATE crash_groups SET status = 'open', updated_at = NOW() WHERE forgejo_issue_id = $1 - `, issueNumber) - if err != nil { - return fmt.Errorf("reopening crash group by issue: %w", err) +func crashGroupFromModel(m CrashGroup) cairnapi.CrashGroup { + return cairnapi.CrashGroup{ + ID: m.ID, + RepositoryID: m.RepositoryID, + CrashSignatureID: m.CrashSignatureID, + Title: m.Title, + Status: m.Status, + ForgejoIssueID: m.ForgejoIssueID, + FirstSeenAt: m.FirstSeenAt, + LastSeenAt: m.LastSeenAt, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, } - return nil -} - -// UpdateArtifactSignature links an artifact to a signature. -func UpdateArtifactSignature(ctx context.Context, pool *pgxpool.Pool, artifactID, signatureID, fingerprint string) error { - _, err := pool.Exec(ctx, ` - UPDATE artifacts SET signature_id = $1, fingerprint = $2 WHERE id = $3 - `, signatureID, fingerprint, artifactID) - if err != nil { - return fmt.Errorf("updating artifact signature: %w", err) - } - return nil -} - -// SearchArtifacts performs full-text search on artifacts. -func SearchArtifacts(ctx context.Context, pool *pgxpool.Pool, query string, limit, offset int) ([]Artifact, int, error) { - if limit <= 0 { - limit = 50 - } - - var total int - err := pool.QueryRow(ctx, ` - SELECT COUNT(*) - FROM artifacts a - WHERE a.search_vector @@ plainto_tsquery('english', $1) - `, query).Scan(&total) - if err != nil { - return nil, 0, fmt.Errorf("counting search results: %w", err) - } - - rows, err := pool.Query(ctx, ` - SELECT a.id, a.repository_id, a.commit_id, a.build_id, a.type, a.blob_key, a.blob_size, - a.crash_message, a.stack_trace, a.tags, a.metadata, a.created_at, - r.name, c.sha - FROM artifacts a - JOIN repositories r ON r.id = a.repository_id - JOIN commits c ON c.id = a.commit_id - WHERE a.search_vector @@ plainto_tsquery('english', $1) - ORDER BY ts_rank(a.search_vector, plainto_tsquery('english', $1)) DESC - LIMIT $2 OFFSET $3 - `, query, limit, offset) - if err != nil { - return nil, 0, fmt.Errorf("searching artifacts: %w", err) - } - defer rows.Close() - - var artifacts []Artifact - for rows.Next() { - var a Artifact - if err := rows.Scan( - &a.ID, &a.RepositoryID, &a.CommitID, &a.BuildID, &a.Type, &a.BlobKey, &a.BlobSize, - &a.CrashMessage, &a.StackTrace, &a.Tags, &a.Metadata, &a.CreatedAt, - &a.RepoName, &a.CommitSHA, - ); err != nil { - return nil, 0, fmt.Errorf("scanning search result: %w", err) - } - artifacts = append(artifacts, a) - } - return artifacts, total, nil } diff --git a/internal/models/models.go b/internal/models/models.go index 99fee0e..2ba1ecc 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -6,50 +6,115 @@ import ( ) type Repository struct { - ID string `json:"id"` - Name string `json:"name"` - Owner string `json:"owner"` - ForgejoURL string `json:"forgejo_url,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey"` + Name string `gorm:"not null;index:idx_repositories_owner_name,unique"` + Owner string `gorm:"not null;index:idx_repositories_owner_name,unique"` + CreatedAt time.Time + UpdatedAt time.Time } +func (Repository) TableName() string { return "repositories" } + type Commit struct { - ID string `json:"id"` - RepositoryID string `json:"repository_id"` - SHA string `json:"sha"` - Author *string `json:"author,omitempty"` - Message *string `json:"message,omitempty"` - Branch *string `json:"branch,omitempty"` - CommittedAt *time.Time `json:"committed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index:idx_commits_repository_sha,unique"` + SHA string `gorm:"not null;index:idx_commits_repository_sha,unique"` + Author *string + Message *string + Branch *string + CommittedAt *time.Time + CreatedAt time.Time + + Repository Repository `gorm:"foreignKey:RepositoryID"` } +func (Commit) TableName() string { return "commits" } + type Build struct { - ID string `json:"id"` - RepositoryID string `json:"repository_id"` - CommitID string `json:"commit_id"` - Builder *string `json:"builder,omitempty"` - BuildFlags *string `json:"build_flags,omitempty"` - Tags json.RawMessage `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index"` + CommitID uint `gorm:"not null;index"` + Builder *string + BuildFlags *string + Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"` + CreatedAt time.Time + + Repository Repository `gorm:"foreignKey:RepositoryID"` + Commit Commit `gorm:"foreignKey:CommitID"` } +func (Build) TableName() string { return "builds" } + type Artifact struct { - ID string `json:"id"` - RepositoryID string `json:"repository_id"` - CommitID string `json:"commit_id"` - BuildID *string `json:"build_id,omitempty"` - Type string `json:"type"` - BlobKey string `json:"blob_key"` - BlobSize int64 `json:"blob_size"` - CrashMessage *string `json:"crash_message,omitempty"` - StackTrace *string `json:"stack_trace,omitempty"` - Tags json.RawMessage `json:"tags,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index"` + CommitID uint `gorm:"not null;index"` + BuildID *uint `gorm:"index"` + CampaignID *uint `gorm:"index"` + CrashSignatureID *uint `gorm:"index"` + Type string `gorm:"not null;index"` + BlobKey string `gorm:"not null"` + BlobSize int64 `gorm:"not null"` + CrashMessage *string + StackTrace *string + Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"` + Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"` + CreatedAt time.Time - // Joined fields for display. - RepoName string `json:"repo_name,omitempty"` - CommitSHA string `json:"commit_sha,omitempty"` + Repository Repository `gorm:"foreignKey:RepositoryID"` + Commit Commit `gorm:"foreignKey:CommitID"` + Build *Build `gorm:"foreignKey:BuildID"` + Campaign *Campaign `gorm:"foreignKey:CampaignID"` + Signature *CrashSignature `gorm:"foreignKey:CrashSignatureID"` } + +func (Artifact) TableName() string { return "artifacts" } + +type Campaign struct { + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index"` + Name string `gorm:"not null"` + Type string `gorm:"not null"` + Status string `gorm:"not null;default:running;index"` + StartedAt time.Time `gorm:"not null;autoCreateTime"` + FinishedAt *time.Time + Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"` + Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"` + CreatedAt time.Time + + Repository Repository `gorm:"foreignKey:RepositoryID"` +} + +func (Campaign) TableName() string { return "campaigns" } + +type CrashSignature struct { + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index:idx_signatures_repo_fingerprint,unique"` + Fingerprint string `gorm:"not null;index:idx_signatures_repo_fingerprint,unique"` + SampleTrace *string + FirstSeenAt time.Time `gorm:"not null;autoCreateTime"` + LastSeenAt time.Time `gorm:"not null;autoCreateTime"` + OccurrenceCount uint `gorm:"not null;default:1"` + + Repository Repository `gorm:"foreignKey:RepositoryID"` +} + +func (CrashSignature) TableName() string { return "crash_signatures" } + +type CrashGroup struct { + ID uint `gorm:"primaryKey"` + RepositoryID uint `gorm:"not null;index"` + CrashSignatureID uint `gorm:"not null;index"` + Title string `gorm:"not null"` + Status string `gorm:"not null;default:open;index"` + ForgejoIssueID *int + FirstSeenAt time.Time `gorm:"not null;autoCreateTime"` + LastSeenAt time.Time `gorm:"not null;autoCreateTime"` + CreatedAt time.Time + UpdatedAt time.Time + + Repository Repository `gorm:"foreignKey:RepositoryID"` + CrashSignature CrashSignature `gorm:"foreignKey:CrashSignatureID"` +} + +func (CrashGroup) TableName() string { return "crash_groups" } diff --git a/internal/models/repository.go b/internal/models/repository.go index 0b89ec4..e5219da 100644 --- a/internal/models/repository.go +++ b/internal/models/repository.go @@ -4,64 +4,55 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" + "gorm.io/gorm" ) -func GetOrCreateRepository(ctx context.Context, pool *pgxpool.Pool, owner, name string) (*Repository, error) { - repo := &Repository{} - err := pool.QueryRow(ctx, ` - INSERT INTO repositories (owner, name) - VALUES ($1, $2) - ON CONFLICT (name) DO UPDATE SET updated_at = NOW() - RETURNING id, name, owner, forgejo_url, created_at, updated_at - `, owner, name).Scan(&repo.ID, &repo.Name, &repo.Owner, &repo.ForgejoURL, &repo.CreatedAt, &repo.UpdatedAt) - if err != nil { +func GetOrCreateRepository(ctx context.Context, db *gorm.DB, owner, name string) (*cairnapi.Repository, error) { + repo := &Repository{Owner: owner, Name: name} + if err := db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).FirstOrCreate(repo).Error; err != nil { return nil, fmt.Errorf("get or create repository: %w", err) } - return repo, nil + out := repositoryFromModel(*repo) + return &out, nil } -func GetRepositoryByName(ctx context.Context, pool *pgxpool.Pool, name string) (*Repository, error) { +func GetRepositoryByName(ctx context.Context, db *gorm.DB, name string) (*cairnapi.Repository, error) { repo := &Repository{} - err := pool.QueryRow(ctx, ` - SELECT id, name, owner, forgejo_url, created_at, updated_at - FROM repositories WHERE name = $1 - `, name).Scan(&repo.ID, &repo.Name, &repo.Owner, &repo.ForgejoURL, &repo.CreatedAt, &repo.UpdatedAt) - if err != nil { + if err := db.WithContext(ctx).Where("name = ?", name).First(repo).Error; err != nil { return nil, fmt.Errorf("get repository by name: %w", err) } - return repo, nil + out := repositoryFromModel(*repo) + return &out, nil } -func GetRepositoryByID(ctx context.Context, pool *pgxpool.Pool, id string) (*Repository, error) { +func GetRepositoryByID(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Repository, error) { repo := &Repository{} - err := pool.QueryRow(ctx, ` - SELECT id, name, owner, forgejo_url, created_at, updated_at - FROM repositories WHERE id = $1 - `, id).Scan(&repo.ID, &repo.Name, &repo.Owner, &repo.ForgejoURL, &repo.CreatedAt, &repo.UpdatedAt) - if err != nil { + if err := db.WithContext(ctx).First(repo, id).Error; err != nil { return nil, fmt.Errorf("get repository by id: %w", err) } - return repo, nil + out := repositoryFromModel(*repo) + return &out, nil } -func ListRepositories(ctx context.Context, pool *pgxpool.Pool) ([]Repository, error) { - rows, err := pool.Query(ctx, ` - SELECT id, name, owner, forgejo_url, created_at, updated_at - FROM repositories ORDER BY name - `) - if err != nil { +func ListRepositories(ctx context.Context, db *gorm.DB) ([]cairnapi.Repository, error) { + var dbRepos []Repository + if err := db.WithContext(ctx).Order("name ASC").Find(&dbRepos).Error; err != nil { return nil, fmt.Errorf("listing repositories: %w", err) } - defer rows.Close() - - var repos []Repository - for rows.Next() { - var r Repository - if err := rows.Scan(&r.ID, &r.Name, &r.Owner, &r.ForgejoURL, &r.CreatedAt, &r.UpdatedAt); err != nil { - return nil, fmt.Errorf("scanning repository: %w", err) - } - repos = append(repos, r) + out := make([]cairnapi.Repository, 0, len(dbRepos)) + for _, m := range dbRepos { + out = append(out, repositoryFromModel(m)) + } + return out, nil +} + +func repositoryFromModel(m Repository) cairnapi.Repository { + return cairnapi.Repository{ + ID: m.ID, + Name: m.Name, + Owner: m.Owner, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, } - return repos, nil } diff --git a/internal/regression/regression.go b/internal/regression/regression.go index 363c166..094dcaf 100644 --- a/internal/regression/regression.go +++ b/internal/regression/regression.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/gorm" ) // Result holds the regression comparison between two commits. @@ -19,13 +19,13 @@ type Result struct { } // Compare computes the set difference of crash fingerprints between a base and head commit. -func Compare(ctx context.Context, pool *pgxpool.Pool, repoID, baseSHA, headSHA string) (*Result, error) { - baseFingerprints, err := fingerprintsForCommit(ctx, pool, repoID, baseSHA) +func Compare(ctx context.Context, db *gorm.DB, repoID uint, baseSHA, headSHA string) (*Result, error) { + baseFingerprints, err := fingerprintsForCommit(ctx, db, repoID, baseSHA) if err != nil { return nil, fmt.Errorf("base commit fingerprints: %w", err) } - headFingerprints, err := fingerprintsForCommit(ctx, pool, repoID, headSHA) + headFingerprints, err := fingerprintsForCommit(ctx, db, repoID, headSHA) if err != nil { return nil, fmt.Errorf("head commit fingerprints: %w", err) } @@ -59,26 +59,19 @@ func Compare(ctx context.Context, pool *pgxpool.Pool, repoID, baseSHA, headSHA s }, nil } -func fingerprintsForCommit(ctx context.Context, pool *pgxpool.Pool, repoID, sha string) ([]string, error) { - rows, err := pool.Query(ctx, ` - SELECT DISTINCT a.fingerprint - FROM artifacts a - JOIN commits c ON c.id = a.commit_id - WHERE a.repository_id = $1 AND c.sha = $2 AND a.fingerprint IS NOT NULL - `, repoID, sha) +func fingerprintsForCommit(ctx context.Context, db *gorm.DB, repoID uint, sha string) ([]string, error) { + var fps []string + err := db.WithContext(ctx). + Table("artifacts"). + Distinct("crash_signatures.fingerprint"). + Joins("JOIN commits ON commits.id = artifacts.commit_id"). + Joins("JOIN crash_signatures ON crash_signatures.id = artifacts.crash_signature_id"). + Where("artifacts.repository_id = ? AND commits.sha = ?", repoID, sha). + Where("artifacts.crash_signature_id IS NOT NULL"). + Pluck("crash_signatures.fingerprint", &fps).Error if err != nil { return nil, err } - defer rows.Close() - - var fps []string - for rows.Next() { - var fp string - if err := rows.Scan(&fp); err != nil { - return nil, err - } - fps = append(fps, fp) - } return fps, nil } diff --git a/internal/regression/regression_test.go b/internal/regression/regression_test.go new file mode 100644 index 0000000..7a5c649 --- /dev/null +++ b/internal/regression/regression_test.go @@ -0,0 +1,17 @@ +package regression + +import "testing" + +func TestToSetDeduplicates(t *testing.T) { + items := []string{"a", "b", "a", "c"} + s := toSet(items) + + if len(s) != 3 { + t.Fatalf("expected 3 unique entries, got %d", len(s)) + } + for _, key := range []string{"a", "b", "c"} { + if !s[key] { + t.Fatalf("expected key %q in set", key) + } + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go index f6150a7..0e1298c 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -5,17 +5,18 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "github.com/mattnite/cairn/internal/blob" "github.com/mattnite/cairn/internal/forgejo" "github.com/mattnite/cairn/internal/handler" assets "github.com/mattnite/cairn/web" + "gorm.io/gorm" ) type RouterConfig struct { - Pool *pgxpool.Pool + DB *gorm.DB Store blob.Store ForgejoClient *forgejo.Client + ForgejoURL string WebhookSecret string } @@ -25,17 +26,17 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) { return nil, err } - forgejoSync := &forgejo.Sync{Client: cfg.ForgejoClient, Pool: cfg.Pool} + forgejoSync := &forgejo.Sync{Client: cfg.ForgejoClient, DB: cfg.DB} - pages := &PageHandler{Pool: cfg.Pool, Templates: templates} - ingest := &handler.IngestHandler{Pool: cfg.Pool, Store: cfg.Store, ForgejoSync: forgejoSync} - artifactAPI := &handler.ArtifactHandler{Pool: cfg.Pool} - download := &handler.DownloadHandler{Pool: cfg.Pool, Store: cfg.Store} - crashGroupAPI := &handler.CrashGroupHandler{Pool: cfg.Pool} - searchAPI := &handler.SearchHandler{Pool: cfg.Pool} - regressionAPI := &handler.RegressionHandler{Pool: cfg.Pool, ForgejoSync: forgejoSync} - campaignAPI := &handler.CampaignHandler{Pool: cfg.Pool} - dashboardAPI := &handler.DashboardHandler{Pool: cfg.Pool} + pages := &PageHandler{DB: cfg.DB, Templates: templates, ForgejoURL: cfg.ForgejoURL} + ingest := &handler.IngestHandler{DB: cfg.DB, Store: cfg.Store, ForgejoSync: forgejoSync} + artifactAPI := &handler.ArtifactHandler{DB: cfg.DB} + download := &handler.DownloadHandler{DB: cfg.DB, Store: cfg.Store} + crashGroupAPI := &handler.CrashGroupHandler{DB: cfg.DB} + searchAPI := &handler.SearchHandler{DB: cfg.DB} + regressionAPI := &handler.RegressionHandler{DB: cfg.DB, ForgejoSync: forgejoSync} + campaignAPI := &handler.CampaignHandler{DB: cfg.DB} + dashboardAPI := &handler.DashboardHandler{DB: cfg.DB} webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret} gin.SetMode(gin.ReleaseMode) diff --git a/internal/web/web.go b/internal/web/web.go index b9357aa..e59437d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,18 +1,22 @@ package web import ( + "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" + cairnapi "github.com/mattnite/cairn/internal/api" "github.com/mattnite/cairn/internal/models" "github.com/mattnite/cairn/internal/regression" + "gorm.io/gorm" ) type PageHandler struct { - Pool *pgxpool.Pool - Templates *Templates + DB *gorm.DB + Templates *Templates + ForgejoURL string } type PageData struct { @@ -23,48 +27,39 @@ type PageData struct { func (h *PageHandler) Index(c *gin.Context) { ctx := c.Request.Context() - artifacts, total, err := models.ListArtifacts(ctx, h.Pool, models.ListArtifactsParams{ - Limit: 10, - }) + artifacts, total, err := models.ListArtifacts(ctx, h.DB, models.ListArtifactsParams{Limit: 10}) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } - repos, err := models.ListRepositories(ctx, h.Pool) + repos, err := models.ListRepositories(ctx, h.DB) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } - var totalCG, openCG int - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&totalCG) - _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&openCG) + var totalCG, openCG int64 + _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Count(&totalCG).Error + _ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Where("status = ?", "open").Count(&openCG).Error - // Top crashers + // Top crashers. type topCrasher struct { - CrashGroupID string + CrashGroupID uint Title string - OccurrenceCount int + OccurrenceCount uint RepoName string } var topCrashers []topCrasher - rows, err := h.Pool.Query(ctx, ` - SELECT cg.id, cg.title, cs.occurrence_count, r.name - FROM crash_groups cg - JOIN crash_signatures cs ON cs.id = cg.crash_signature_id - JOIN repositories r ON r.id = cg.repository_id - WHERE cg.status = 'open' - ORDER BY cs.occurrence_count DESC - LIMIT 5 - `) + groups, _, err := models.ListCrashGroups(ctx, h.DB, nil, "open", 5, 0) if err == nil { - defer rows.Close() - for rows.Next() { - var tc topCrasher - if rows.Scan(&tc.CrashGroupID, &tc.Title, &tc.OccurrenceCount, &tc.RepoName) == nil { - topCrashers = append(topCrashers, tc) - } + for _, group := range groups { + topCrashers = append(topCrashers, topCrasher{ + CrashGroupID: group.ID, + Title: group.Title, + OccurrenceCount: group.OccurrenceCount, + RepoName: group.RepoName, + }) } } @@ -72,10 +67,10 @@ func (h *PageHandler) Index(c *gin.Context) { Title: "Dashboard", Content: map[string]any{ "Artifacts": artifacts, - "TotalArtifacts": total, + "TotalArtifacts": int(total), "Repositories": repos, - "TotalCrashGroups": totalCG, - "OpenCrashGroups": openCG, + "TotalCrashGroups": int(totalCG), + "OpenCrashGroups": int(openCG), "TopCrashers": topCrashers, }, } @@ -90,8 +85,14 @@ func (h *PageHandler) Artifacts(c *gin.Context) { limit = 50 } - artifacts, total, err := models.ListArtifacts(c.Request.Context(), h.Pool, models.ListArtifactsParams{ - RepositoryID: c.Query("repository_id"), + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + artifacts, total, err := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{ + RepositoryID: repoID, Type: c.Query("type"), Limit: limit, Offset: offset, @@ -105,7 +106,7 @@ func (h *PageHandler) Artifacts(c *gin.Context) { Title: "Artifacts", Content: map[string]any{ "Artifacts": artifacts, - "Total": total, + "Total": int(total), "Limit": limit, "Offset": offset, }, @@ -115,16 +116,20 @@ func (h *PageHandler) Artifacts(c *gin.Context) { } func (h *PageHandler) ArtifactDetail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "artifact id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } - artifact, err := models.GetArtifact(c.Request.Context(), h.Pool, id) + artifact, err := models.GetArtifact(c.Request.Context(), h.DB, id) if err != nil { c.String(http.StatusNotFound, "artifact not found") return } data := PageData{ - Title: "Artifact " + artifact.ID[:8], + Title: fmt.Sprintf("Artifact %d", artifact.ID), Content: artifact, } c.Header("Content-Type", "text/html; charset=utf-8") @@ -132,7 +137,7 @@ func (h *PageHandler) ArtifactDetail(c *gin.Context) { } func (h *PageHandler) Repos(c *gin.Context) { - repos, err := models.ListRepositories(c.Request.Context(), h.Pool) + repos, err := models.ListRepositories(c.Request.Context(), h.DB) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return @@ -155,11 +160,13 @@ func (h *PageHandler) CrashGroups(c *gin.Context) { limit = 50 } - groups, total, err := models.ListCrashGroups( - c.Request.Context(), h.Pool, - c.Query("repository_id"), c.Query("status"), - limit, offset, - ) + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + groups, total, err := models.ListCrashGroups(c.Request.Context(), h.DB, repoID, c.Query("status"), limit, offset) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return @@ -169,7 +176,7 @@ func (h *PageHandler) CrashGroups(c *gin.Context) { Title: "Crash Groups", Content: map[string]any{ "CrashGroups": groups, - "Total": total, + "Total": int(total), "Limit": limit, "Offset": offset, }, @@ -179,17 +186,29 @@ func (h *PageHandler) CrashGroups(c *gin.Context) { } func (h *PageHandler) CrashGroupDetail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "crash group id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } - group, err := models.GetCrashGroup(c.Request.Context(), h.Pool, id) + group, err := models.GetCrashGroup(c.Request.Context(), h.DB, id) if err != nil { c.String(http.StatusNotFound, "crash group not found") return } + if group.ForgejoIssueID != nil && h.ForgejoURL != "" { + repo, repoErr := models.GetRepositoryByID(c.Request.Context(), h.DB, group.RepositoryID) + if repoErr == nil { + issueURL := fmt.Sprintf("%s/%s/%s/issues/%d", strings.TrimRight(h.ForgejoURL, "/"), repo.Owner, repo.Name, *group.ForgejoIssueID) + group.ForgejoIssueURL = &issueURL + } + } // Get artifacts linked to this crash group's signature. - artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.Pool, models.ListArtifactsParams{ - SignatureID: group.CrashSignatureID, + signatureID := group.CrashSignatureID + artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{ + SignatureID: &signatureID, Limit: 50, }) @@ -207,10 +226,10 @@ func (h *PageHandler) CrashGroupDetail(c *gin.Context) { func (h *PageHandler) Search(c *gin.Context) { q := c.Query("q") - var artifacts []models.Artifact - var total int + var artifacts []cairnapi.Artifact + var total int64 if q != "" { - artifacts, total, _ = models.SearchArtifacts(c.Request.Context(), h.Pool, q, 50, 0) + artifacts, total, _ = models.SearchArtifacts(c.Request.Context(), h.DB, q, 50, 0) } data := PageData{ @@ -218,7 +237,7 @@ func (h *PageHandler) Search(c *gin.Context) { Content: map[string]any{ "Query": q, "Artifacts": artifacts, - "Total": total, + "Total": int(total), }, } c.Header("Content-Type", "text/html; charset=utf-8") @@ -237,9 +256,9 @@ func (h *PageHandler) Regression(c *gin.Context) { } if repo != "" && base != "" && head != "" { - r, err := models.GetRepositoryByName(c.Request.Context(), h.Pool, repo) + r, err := models.GetRepositoryByName(c.Request.Context(), h.DB, repo) if err == nil { - result, err := regression.Compare(c.Request.Context(), h.Pool, r.ID, base, head) + result, err := regression.Compare(c.Request.Context(), h.DB, r.ID, base, head) if err == nil { result.RepoName = repo content["Result"] = result @@ -262,7 +281,13 @@ func (h *PageHandler) Campaigns(c *gin.Context) { limit = 50 } - campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.Pool, c.Query("repository_id"), limit, offset) + repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return @@ -272,7 +297,7 @@ func (h *PageHandler) Campaigns(c *gin.Context) { Title: "Campaigns", Content: map[string]any{ "Campaigns": campaigns, - "Total": total, + "Total": int(total), }, } c.Header("Content-Type", "text/html; charset=utf-8") @@ -280,16 +305,21 @@ func (h *PageHandler) Campaigns(c *gin.Context) { } func (h *PageHandler) CampaignDetail(c *gin.Context) { - id := c.Param("id") + id, err := parseUintID(c.Param("id"), "campaign id") + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } - campaign, err := models.GetCampaign(c.Request.Context(), h.Pool, id) + campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id) if err != nil { c.String(http.StatusNotFound, "campaign not found") return } - artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.Pool, models.ListArtifactsParams{ - CampaignID: campaign.ID, + campaignID := campaign.ID + artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{ + CampaignID: &campaignID, Limit: 50, }) @@ -303,3 +333,22 @@ func (h *PageHandler) CampaignDetail(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") _ = h.Templates.Render(c.Writer, "campaign_detail", data) } + +func parseUintID(raw string, field string) (uint, error) { + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s", field) + } + return uint(id), nil +} + +func parseOptionalUintID(raw string, field string) (*uint, error) { + if raw == "" { + return nil, nil + } + id, err := parseUintID(raw, field) + if err != nil { + return nil, err + } + return &id, nil +}