package handler import ( "archive/tar" "compress/gzip" "fmt" "io" "net/http" "path/filepath" "strconv" "github.com/gin-gonic/gin" cairnapi "github.com/mattnite/cairn/internal/api" "github.com/mattnite/cairn/internal/blob" "github.com/mattnite/cairn/internal/models" "gorm.io/gorm" ) type TargetHandler struct { DB *gorm.DB Store blob.Store } func (h *TargetHandler) List(c *gin.Context) { limit, _ := strconv.Atoi(c.Query("limit")) offset, _ := strconv.Atoi(c.Query("offset")) if limit <= 0 { limit = 50 } repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } targets, total, err := models.ListTargets(c.Request.Context(), h.DB, repoID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if targets == nil { targets = []cairnapi.Target{} } c.JSON(http.StatusOK, gin.H{ "targets": targets, "total": total, "limit": limit, "offset": offset, }) } func (h *TargetHandler) Detail(c *gin.Context) { id, err := parseUintID(c.Param("id"), "target id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } target, err := models.GetTarget(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) return } c.JSON(http.StatusOK, target) } type EnsureTargetRequest struct { Repository string `json:"repository" binding:"required"` Owner string `json:"owner" binding:"required"` Name string `json:"name" binding:"required"` Type string `json:"type" binding:"required"` } func (h *TargetHandler) Ensure(c *gin.Context) { var req EnsureTargetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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 } target, err := models.GetOrCreateTarget(ctx, h.DB, models.CreateTargetParams{ RepositoryID: repo.ID, Name: req.Name, Type: req.Type, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, target) } // Run handlers type RunHandler struct { DB *gorm.DB } type StartRunRequest struct { TargetID uint `json:"target_id" binding:"required"` CommitSHA string `json:"commit_sha" binding:"required"` } func (h *RunHandler) Start(c *gin.Context) { var req StartRunRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Look up the target to get the repository ID for the commit. target, err := models.GetTarget(ctx, h.DB, req.TargetID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) return } commit := &models.Commit{RepositoryID: target.RepositoryID, SHA: req.CommitSHA} if err := h.DB.WithContext(ctx).Where("repository_id = ? AND sha = ?", target.RepositoryID, req.CommitSHA).FirstOrCreate(commit).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } run, err := models.CreateRun(ctx, h.DB, req.TargetID, commit.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, run) } func (h *RunHandler) Finish(c *gin.Context) { id, err := parseUintID(c.Param("id"), "run id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := models.FinishRun(c.Request.Context(), h.DB, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "finished"}) } func (h *RunHandler) Detail(c *gin.Context) { id, err := parseUintID(c.Param("id"), "run id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } run, err := models.GetRun(c.Request.Context(), h.DB, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "run not found"}) return } c.JSON(http.StatusOK, run) } func (h *RunHandler) List(c *gin.Context) { limit, _ := strconv.Atoi(c.Query("limit")) offset, _ := strconv.Atoi(c.Query("offset")) if limit <= 0 { limit = 50 } targetID, err := parseOptionalUintID(c.Query("target_id"), "target_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } runs, total, err := models.ListRuns(c.Request.Context(), h.DB, targetID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if runs == nil { runs = []cairnapi.Run{} } c.JSON(http.StatusOK, gin.H{ "runs": runs, "total": total, "limit": limit, "offset": offset, }) } // Corpus handlers type CorpusHandler struct { DB *gorm.DB Store blob.Store } func (h *CorpusHandler) Upload(c *gin.Context) { targetID, err := parseUintID(c.Param("id"), "target id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Verify target exists. target, err := models.GetTarget(ctx, h.DB, targetID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) 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() var runID *uint if rid := c.PostForm("run_id"); rid != "" { id, err := strconv.ParseUint(rid, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run_id"}) return } uid := uint(id) runID = &uid } // Use a unique prefix to avoid filename collisions across runs. var entryCount int64 h.DB.WithContext(ctx).Model(&models.CorpusEntry{}).Where("target_id = ?", targetID).Count(&entryCount) blobKey := fmt.Sprintf("corpus/%s/%s/%d-%s", target.RepoName, target.Name, entryCount, 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 } entry, err := models.CreateCorpusEntry(ctx, h.DB, models.CreateCorpusEntryParams{ TargetID: targetID, RunID: runID, BlobKey: blobKey, BlobSize: header.Size, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, entry) } func (h *CorpusHandler) List(c *gin.Context) { targetID, err := parseUintID(c.Param("id"), "target id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } limit, _ := strconv.Atoi(c.Query("limit")) offset, _ := strconv.Atoi(c.Query("offset")) entries, total, err := models.ListCorpusEntries(c.Request.Context(), h.DB, targetID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if entries == nil { entries = []cairnapi.CorpusEntry{} } c.JSON(http.StatusOK, gin.H{ "entries": entries, "total": total, "limit": limit, "offset": offset, }) } func (h *CorpusHandler) Download(c *gin.Context) { entryID, err := parseUintID(c.Param("entry_id"), "corpus entry id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() entry := &models.CorpusEntry{} if err := h.DB.WithContext(ctx).First(entry, entryID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "corpus entry not found"}) return } reader, err := h.Store.Get(ctx, entry.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", entry.BlobKey)) c.Header("Content-Type", "application/octet-stream") _, _ = io.Copy(c.Writer, reader) } func (h *CorpusHandler) DownloadAll(c *gin.Context) { targetID, err := parseUintID(c.Param("id"), "target id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() var entries []models.CorpusEntry if err := h.DB.WithContext(ctx).Where("target_id = ?", targetID).Find(&entries).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(entries) == 0 { c.Status(http.StatusNoContent) return } c.Header("Content-Type", "application/gzip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"corpus-%d.tar.gz\"", targetID)) gw := gzip.NewWriter(c.Writer) defer gw.Close() tw := tar.NewWriter(gw) defer tw.Close() for _, entry := range entries { reader, err := h.Store.Get(ctx, entry.BlobKey) if err != nil { continue } data, err := io.ReadAll(reader) reader.Close() if err != nil { continue } hdr := &tar.Header{ Name: fmt.Sprintf("%d-%s", entry.ID, filepath.Base(entry.BlobKey)), Mode: 0o644, Size: int64(len(data)), } if err := tw.WriteHeader(hdr); err != nil { return } if _, err := tw.Write(data); err != nil { return } } }