package handlers import ( "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/models" ) type PipelineHandler struct { db *xorm.Engine } func NewPipelineHandler(db *xorm.Engine) *PipelineHandler { return &PipelineHandler{db: db} } // ListPipelines returns all pipeline definitions for a repository. func (h *PipelineHandler) ListPipelines(w http.ResponseWriter, r *http.Request) { repoID, ok := h.repoID(w, r) if !ok { return } var pipelines []models.Pipeline if err := h.db.Where("repo_id = ?", repoID).Find(&pipelines); err != nil { jsonError(w, "could not list pipelines", http.StatusInternalServerError) return } if pipelines == nil { pipelines = []models.Pipeline{} } jsonOK(w, pipelines) } // ListRuns returns pipeline runs for a repository, most recent first. func (h *PipelineHandler) ListRuns(w http.ResponseWriter, r *http.Request) { repoID, ok := h.repoID(w, r) if !ok { return } limit := 30 if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { limit = l } var runs []models.PipelineRun if err := h.db.Where("repo_id = ?", repoID).Desc("id").Limit(limit).Find(&runs); err != nil { jsonError(w, "could not list runs", http.StatusInternalServerError) return } if runs == nil { runs = []models.PipelineRun{} } jsonOK(w, runs) } type runDetailResponse struct { models.PipelineRun Jobs []jobDetailResponse `json:"jobs"` } type jobDetailResponse struct { models.PipelineJob Steps []models.PipelineStep `json:"steps"` } // GetRun returns a run with its full job + step tree. func (h *PipelineHandler) GetRun(w http.ResponseWriter, r *http.Request) { run, ok := h.lookupRun(w, r) if !ok { return } var jobs []models.PipelineJob h.db.Where("run_id = ?", run.ID).Asc("id").Find(&jobs) jobDetails := make([]jobDetailResponse, len(jobs)) for i, job := range jobs { var steps []models.PipelineStep h.db.Where("job_id = ?", job.ID).Asc("seq").Find(&steps) if steps == nil { steps = []models.PipelineStep{} } jobDetails[i] = jobDetailResponse{PipelineJob: job, Steps: steps} } jsonOK(w, runDetailResponse{PipelineRun: *run, Jobs: jobDetails}) } // GetJobLogs returns all log chunks for a job, ordered by step seq and chunk index. func (h *PipelineHandler) GetJobLogs(w http.ResponseWriter, r *http.Request) { _, ok := h.lookupRun(w, r) if !ok { return } jobID, err := strconv.ParseInt(chi.URLParam(r, "jobID"), 10, 64) if err != nil { jsonError(w, "invalid job ID", http.StatusBadRequest) return } // Verify job belongs to this run. var job models.PipelineJob runID, _ := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) if found, _ := h.db.Where("id = ? AND run_id = ?", jobID, runID).Get(&job); !found { jsonError(w, "job not found", http.StatusNotFound) return } var steps []models.PipelineStep h.db.Where("job_id = ?", jobID).Asc("seq").Find(&steps) type stepLogs struct { models.PipelineStep Logs []models.PipelineStepLog `json:"logs"` } result := make([]stepLogs, len(steps)) for i, step := range steps { var logs []models.PipelineStepLog h.db.Where("step_id = ?", step.ID).Asc("chunk_index").Find(&logs) if logs == nil { logs = []models.PipelineStepLog{} } result[i] = stepLogs{PipelineStep: step, Logs: logs} } jsonOK(w, result) } // CancelRun marks a queued or running run as cancelled. func (h *PipelineHandler) CancelRun(w http.ResponseWriter, r *http.Request) { run, ok := h.lookupRun(w, r) if !ok { return } if run.Status != "queued" && run.Status != "running" { jsonError(w, "run is not cancellable in its current state", http.StatusConflict) return } now := time.Now().UTC() run.Status = "cancelled" run.FinishedAt = &now if _, err := h.db.ID(run.ID).Cols("status", "finished_at").Update(run); err != nil { jsonError(w, "could not cancel run", http.StatusInternalServerError) return } // Cancel any queued jobs. h.db.Where("run_id = ? AND status = 'queued'", run.ID). //nolint:errcheck Cols("status").Update(&models.PipelineJob{Status: "cancelled"}) jsonOK(w, run) } // RetryJob re-queues a failed job by resetting its status and re-publishing job.queued. func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) { run, ok := h.lookupRun(w, r) if !ok { return } jobID, err := strconv.ParseInt(chi.URLParam(r, "jobID"), 10, 64) if err != nil { jsonError(w, "invalid job ID", http.StatusBadRequest) return } var job models.PipelineJob if found, _ := h.db.Where("id = ? AND run_id = ?", jobID, run.ID).Get(&job); !found { jsonError(w, "job not found", http.StatusNotFound) return } if job.Status != "failed" && job.Status != "cancelled" { jsonError(w, "only failed or cancelled jobs can be retried", http.StatusConflict) return } job.Status = "queued" job.StartedAt = nil job.FinishedAt = nil h.db.ID(job.ID).Cols("status", "started_at", "finished_at").Update(&job) //nolint:errcheck // Reset step statuses. h.db.Where("job_id = ?", job.ID).Cols("status", "exit_code", "started_at", "finished_at"). //nolint:errcheck Update(&models.PipelineStep{Status: "queued"}) // Also reset the run status if it was failed/cancelled. if run.Status == "failed" || run.Status == "cancelled" { run.Status = "running" run.FinishedAt = nil h.db.ID(run.ID).Cols("status", "finished_at").Update(run) //nolint:errcheck } jsonOK(w, job) } // ── Helpers ─────────────────────────────────────────────────────────────────── func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) { owner := chi.URLParam(r, "owner") repoName := chi.URLParam(r, "repo") var u models.User if found, _ := h.db.Where("username = ?", owner).Get(&u); !found { jsonError(w, "repository not found", http.StatusNotFound) return 0, false } var repo models.Repository if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found { jsonError(w, "repository not found", http.StatusNotFound) return 0, false } return repo.ID, true } func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) { repoID, ok := h.repoID(w, r) if !ok { return nil, false } runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) if err != nil { jsonError(w, "invalid run ID", http.StatusBadRequest) return nil, false } var run models.PipelineRun if found, _ := h.db.Where("id = ? AND repo_id = ?", runID, repoID).Get(&run); !found { jsonError(w, "run not found", http.StatusNotFound) return nil, false } return &run, true }