cairn/internal/handler/ingest.go

167 lines
4.8 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"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 {
DB *gorm.DB
Store blob.Store
ForgejoSync *forgejo.Sync
}
type IngestRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
CommitSHA string `json:"commit_sha"`
RunID *uint `json:"run_id,omitempty"`
Type string `json:"type"`
CrashMessage string `json:"crash_message,omitempty"`
StackTrace string `json:"stack_trace,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func (h *IngestHandler) Create(c *gin.Context) {
metaJSON := c.PostForm("meta")
if metaJSON == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'meta' form field"})
return
}
var req IngestRequest
if err := json.Unmarshal([]byte(metaJSON), &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid meta JSON: " + err.Error()})
return
}
if req.Repository == "" || req.Owner == "" || req.CommitSHA == "" || req.Type == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "repository, owner, commit_sha, and type are required"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'file' form field: " + err.Error()})
return
}
defer file.Close()
ctx := c.Request.Context()
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 := &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
}
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()})
return
}
var crashMsg, stackTrace *string
if req.CrashMessage != "" {
crashMsg = &req.CrashMessage
}
if req.StackTrace != "" {
stackTrace = &req.StackTrace
}
artifact, err := models.CreateArtifact(ctx, h.DB, models.CreateArtifactParams{
RepositoryID: repo.ID,
CommitID: commit.ID,
RunID: req.RunID,
Type: req.Type,
BlobKey: blobKey,
BlobSize: header.Size,
CrashMessage: crashMsg,
StackTrace: stackTrace,
Tags: req.Tags,
Metadata: req.Metadata,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 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.DB, repo.ID, result.Fingerprint, stackTrace)
if err == nil {
_ = 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 && strings.TrimSpace(result.Frames[0].Function) != "" {
title = req.Type + ": " + result.Frames[0].Function
}
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)
}
}
}
}
}
c.JSON(http.StatusCreated, artifact)
}
type DownloadHandler struct {
DB *gorm.DB
Store blob.Store
}
func (h *DownloadHandler) Download(c *gin.Context) {
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.DB, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "artifact not found"})
return
}
reader, err := h.Store.Get(c.Request.Context(), artifact.BlobKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "reading blob: " + err.Error()})
return
}
defer reader.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", artifact.BlobKey))
c.Header("Content-Type", "application/octet-stream")
_, _ = io.Copy(c.Writer, reader)
}