Reorganize internals
This commit is contained in:
parent
6e6f446eb0
commit
684c30f8b8
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
9
go.mod
|
|
@ -4,9 +4,11 @@ go 1.25.7
|
|||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/rs/zerolog v1.34.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -26,7 +28,10 @@ require (
|
|||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
|
|
@ -34,6 +39,7 @@ require (
|
|||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
|
|
@ -56,4 +62,5 @@ require (
|
|||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
)
|
||||
|
|
|
|||
16
go.sum
16
go.sum
|
|
@ -42,12 +42,20 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
|
|
@ -67,6 +75,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
|||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
|
|
@ -142,3 +152,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,18 @@ 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"`
|
||||
CrashGroups []cairnapi.CrashGroup `json:"crash_groups"`
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,17 +5,18 @@ 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"`
|
||||
Artifacts []cairnapi.Artifact `json:"artifacts"`
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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,
|
||||
}
|
||||
repos = append(repos, r)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue