package api import ( "encoding/json" "io" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/workload/plugin" ) // getHookKindSchema returns the sample config shape for one registered // plugin kind. Used by /apps/new and the edit form so the JSON editor's // initial body is derived from the plugin itself rather than hardcoded // in the frontend. // // GET /api/hooks/kinds/{kind}/schema func (s *Server) getHookKindSchema(w http.ResponseWriter, r *http.Request) { kind := chi.URLParam(r, "kind") sample, ok := plugin.SchemaSampleFor(kind) if !ok { respondNotFound(w, "plugin kind") return } respondJSON(w, http.StatusOK, map[string]any{ "kind": kind, "sample": sample, }) } // listHookKinds reports every registered Source and Trigger so operators // can verify the plugin registry is wired correctly without writing // a workload. // // GET /api/hooks/kinds func (s *Server) listHookKinds(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{ "sources": plugin.SourceKinds(), "triggers": plugin.TriggerKinds(), }) } // dispatchGeneric accepts a pre-normalized InboundEvent payload and fans // it out across registered triggers. The body shape mirrors // plugin.InboundEvent — vendor-specific webhook parsing (Gitea / GitHub / // generic registry) stays in the legacy /api/webhook/* handlers until // Phase 5 of the refactor migrates them into trigger-specific ingress. // // POST /api/hooks/generic // { // "kind": "image-push", // "image": { "registry": "...", "repo": "owner/app", "tag": "v1" } // } // // Until the store rewrite lands and workloads carry source_kind / // trigger_kind, the workloads iteration here returns an empty list and // the response reports zero matches. This still exercises the registry // path so operators can curl it and confirm wiring. func (s *Server) dispatchGeneric(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { respondError(w, http.StatusBadRequest, "read body: "+err.Error()) return } var evt plugin.InboundEvent if err := json.Unmarshal(body, &evt); err != nil { respondError(w, http.StatusBadRequest, "invalid InboundEvent: "+err.Error()) return } if evt.Kind == "" { respondError(w, http.StatusBadRequest, "kind is required") return } ctx := r.Context() triggers := plugin.AllTriggers() workloads := listPluginWorkloads(s) deps := s.deployer.PluginDeps() type matchReport struct { WorkloadID string `json:"workload_id"` TriggerKind string `json:"trigger_kind"` Reference string `json:"reference"` Dispatched bool `json:"dispatched"` DispatchError string `json:"dispatch_error,omitempty"` } matches := []matchReport{} for _, wl := range workloads { trig, ok := triggers[wl.TriggerKind] if !ok { continue } intent, err := trig.Match(ctx, deps, wl, evt) if err != nil { slog.Warn("hooks: trigger match error", "trigger", wl.TriggerKind, "workload", wl.ID, "error", err) continue } if intent == nil { continue } if intent.TriggeredAt.IsZero() { intent.TriggeredAt = time.Now().UTC() } report := matchReport{ WorkloadID: wl.ID, TriggerKind: wl.TriggerKind, Reference: intent.Reference, } if err := s.deployer.DispatchPlugin(ctx, wl, *intent); err != nil { // Wrapped error can carry registry-auth bytes / compose stdout // (i.e. user secrets baked into the YAML); keep it server-side // only and return a generic flag to the client. slog.Warn("hooks: dispatch failed", "workload", wl.ID, "trigger", wl.TriggerKind, "error", err) report.DispatchError = "dispatch failed; see server logs" } else { report.Dispatched = true } matches = append(matches, report) } respondJSON(w, http.StatusAccepted, map[string]any{ "event_kind": evt.Kind, "examined_triggers": len(triggers), "examined_workloads": len(workloads), "matches": matches, }) } // listPluginWorkloads returns every workload row whose source_kind and // trigger_kind are both set — these are the rows that opted into the new // plugin pipeline. Legacy rows (kind/ref_id pointing at project/stack/site // with empty source_kind) are skipped so the ingress only fires intents // for workloads that have a registered Source + Trigger to dispatch them. func listPluginWorkloads(s *Server) []plugin.Workload { rows, err := s.store.ListWorkloads("") if err != nil { slog.Warn("hooks: list workloads failed", "error", err) return nil } out := make([]plugin.Workload, 0, len(rows)) for _, w := range rows { if w.SourceKind == "" || w.TriggerKind == "" { continue } out = append(out, toPluginWorkload(w)) } return out }