package api import ( "net/http" "strconv" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // Change history and undo (#48). Two endpoints: read a garden's change sets, and // revert one. // historyResponse is the body of GET /gardens/:id/history. hasMore lets the // client page without a separate count query — it asks for one row more than it // shows and reports whether that row existed. type historyResponse struct { ChangeSets []changeSetView `json:"changeSets"` HasMore bool `json:"hasMore"` } // changeSetView is one history entry. It deliberately omits the revisions // themselves: the list renders counts, and nothing in the UI needs the JSON // snapshots. type changeSetView struct { ID int64 `json:"id"` GardenID int64 `json:"gardenId"` ActorID int64 `json:"actorId"` ActorName string `json:"actorName"` Source string `json:"source"` Summary string `json:"summary"` AgentRunID *string `json:"agentRunId,omitempty"` RevertsID *int64 `json:"revertsId,omitempty"` RevertedByID *int64 `json:"revertedById,omitempty"` Counts []countView `json:"counts"` CreatedAt string `json:"createdAt"` } type countView struct { EntityType string `json:"entityType"` Op string `json:"op"` N int `json:"n"` } // revertResponse is the body of POST /change-sets/:id/revert, on both the clean // (201) and partial (409) paths. Carrying both fields either way is what lets the // UI say "2 of 3 changes undone; the north bed was edited since and was left // alone" instead of a generic failure — a partial revert really did change // things, and pretending otherwise would be a lie. type revertResponse struct { ChangeSet *changeSetView `json:"changeSet"` Conflicts []conflictView `json:"conflicts"` } type conflictView struct { EntityType string `json:"entityType"` EntityID int64 `json:"entityId"` Reason string `json:"reason"` Name string `json:"name,omitempty"` } func (h *handlers) getGardenHistory(c *gin.Context) { gardenID, ok := parseIDParam(c, "id") if !ok { return } // 0 means "the service's default"; it clamps the upper bound too. limit := intQuery(c, "limit", 0) offset := intQuery(c, "offset", 0) sets, hasMore, err := h.svc.GardenHistory(c.Request.Context(), mustActor(c).ID, gardenID, limit, offset) if err != nil { writeServiceError(c, err) return } views := make([]changeSetView, 0, len(sets)) for i := range sets { views = append(views, *toChangeSetView(&sets[i])) } c.JSON(http.StatusOK, historyResponse{ChangeSets: views, HasMore: hasMore}) } func (h *handlers) revertChangeSet(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } cs, conflicts, err := h.svc.RevertChangeSet(c.Request.Context(), mustActor(c).ID, id) if err != nil { writeServiceError(c, err) return } body := revertResponse{ChangeSet: toChangeSetView(cs), Conflicts: toConflictViews(conflicts)} if len(conflicts) > 0 { // 409 even when part of the revert applied: something the caller asked for // did not happen, and the body says exactly what. c.JSON(http.StatusConflict, body) return } c.JSON(http.StatusCreated, body) } func toChangeSetView(cs *domain.ChangeSet) *changeSetView { if cs == nil { return nil } counts := make([]countView, 0, len(cs.Counts)) for _, c := range cs.Counts { counts = append(counts, countView{EntityType: c.EntityType, Op: c.Op, N: c.N}) } return &changeSetView{ ID: cs.ID, GardenID: cs.GardenID, ActorID: cs.ActorID, ActorName: cs.ActorName, Source: cs.Source, Summary: cs.Summary, AgentRunID: cs.AgentRunID, RevertsID: cs.RevertsID, RevertedByID: cs.RevertedByID, Counts: counts, CreatedAt: cs.CreatedAt, } } func toConflictViews(cs []domain.RevertConflict) []conflictView { views := make([]conflictView, 0, len(cs)) for _, c := range cs { views = append(views, conflictView{EntityType: c.EntityType, EntityID: c.EntityID, Reason: c.Reason, Name: c.Name}) } return views } // intQuery reads a non-negative integer query parameter, falling back to def on // an absent or malformed value. func intQuery(c *gin.Context, name string, def int) int { raw := c.Query(name) if raw == "" { return def } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return def } return v }