Reorganize internals

This commit is contained in:
Matthew Knight 2026-03-05 18:55:55 -08:00
parent 6e6f446eb0
commit 684c30f8b8
No known key found for this signature in database
34 changed files with 1409 additions and 964 deletions

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}

9
go.mod
View File

@ -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
)

16
go.sum
View File

@ -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=

89
internal/api/types.go Normal file
View File

@ -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"`
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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);

View File

@ -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();

View File

@ -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);

View File

@ -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"`

View File

@ -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

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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{

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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" }

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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
}