package http import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" stdhttp "net/http" "strings" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workitem" "github.com/nomadcode/nomadcode-core/internal/workitempipeline" ) const maxPlaneWebhookBodyBytes = 1 << 20 // planeProvider is the provider-neutral id used for Plane-originated work items. const planeProvider = workitem.ProviderID("plane") type planeWebhookPayload struct { Event string `json:"event"` Action string `json:"action"` WebhookID string `json:"webhook_id"` WorkspaceID string `json:"workspace_id"` Data json.RawMessage `json:"data"` Activity struct { Field string `json:"field"` Actor planeWebhookActor `json:"actor"` } `json:"activity"` } type planeWebhookActor struct { ID string `json:"id"` } // planeWebhookData holds only the raw identity fields needed to locate the work // item later. Current state, assignees, labels, and body from the webhook payload // are intentionally ignored: the payload is a wakeup hint, and the source of // truth is a Plane API re-fetch in a later stage. type planeWebhookData struct { ID string `json:"id"` Project string `json:"project"` Workspace string `json:"workspace"` } // planeWebhookWorkItemEvent is the provider-neutral wakeup hint extracted from a // Plane webhook. It preserves the raw Plane identity (workspace id, project id, // work item id) plus actor/change/action hints for later self-guard and trigger // decisions. It never carries Plane payload state (state/assignees/labels/body) // as truth. // // WorkspaceID is the raw Plane workspace UUID from the payload. It is NOT a // dispatch-ready tenant: the downstream Plane adapter (planeWorkItemRef) and // project-sync target consume a workspace slug as workitem.Ref.Tenant, and the // webhook payload has no slug. Building the workitem.Ref is therefore deferred // to dispatch (02+01_trigger_dispatch), which resolves the workspace id to a // slug and calls WorkItemRef. type planeWebhookWorkItemEvent struct { WorkspaceID string ProjectID string WorkItemID string ActorID string ChangeField string Action string } // WorkItemRef builds a provider-neutral workitem.Ref once the caller supplies a // dispatch-ready workspace slug. The webhook payload only carries a workspace // UUID, so the slug must be resolved by the dispatch stage before a ref that the // Plane adapter / project-sync can consume exists. Blank slug/project/id are // rejected via workitem.NormalizeRef and the explicit check below. func (e planeWebhookWorkItemEvent) WorkItemRef(workspaceSlug string) (workitem.Ref, error) { slug := strings.TrimSpace(workspaceSlug) project := strings.TrimSpace(e.ProjectID) id := strings.TrimSpace(e.WorkItemID) if slug == "" || project == "" || id == "" { return workitem.Ref{}, workitem.ErrInvalidRef } return workitem.NormalizeRef(workitem.Ref{ Provider: planeProvider, Tenant: slug, Project: project, ID: id, }) } // planeWorkItemEvents are the Plane webhook event types that map to a work item. // Other events (e.g. project, cycle, module) are ignored without side effects. func isPlaneWorkItemEvent(event string) bool { switch strings.TrimSpace(strings.ToLower(event)) { case "issue", "work_item", "work-item", "workitem": return true default: return false } } // normalizePlaneWebhookPayload extracts a provider-neutral wakeup hint from a // Plane webhook payload. The second return value reports whether the event is a // relevant work item event; irrelevant events return ok=false with no error so // the caller can ignore them without side effects. An error is returned only // when a relevant event is missing the ref tuple required by dispatch. func normalizePlaneWebhookPayload(payload planeWebhookPayload) (planeWebhookWorkItemEvent, bool, error) { if !isPlaneWorkItemEvent(payload.Event) { return planeWebhookWorkItemEvent{}, false, nil } var data planeWebhookData if len(payload.Data) > 0 && string(payload.Data) != "null" { if err := json.Unmarshal(payload.Data, &data); err != nil { return planeWebhookWorkItemEvent{}, true, err } } // A relevant work item event must carry the raw identity tuple that dispatch // needs to build a ref after resolving the workspace slug. Reject missing // workspace id / project / work item id here as a relevant-but-invalid event // (ok=true, err!=nil) so dispatch never sees an event with empty identity. workspaceID := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID) projectID := strings.TrimSpace(data.Project) workItemID := strings.TrimSpace(data.ID) if workspaceID == "" || projectID == "" || workItemID == "" { return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef } return planeWebhookWorkItemEvent{ WorkspaceID: workspaceID, ProjectID: projectID, WorkItemID: workItemID, ActorID: strings.TrimSpace(payload.Activity.Actor.ID), ChangeField: strings.TrimSpace(payload.Activity.Field), Action: strings.TrimSpace(payload.Action), }, true, nil } func firstNonEmptyTrimmed(vals ...string) string { for _, v := range vals { if trimmed := strings.TrimSpace(v); trimmed != "" { return trimmed } } return "" } func (h *Handler) SetPlaneWebhookSecret(secret string) { h.planeWebhookSecret = strings.TrimSpace(secret) } // SetPlaneWebhookDispatchConfig wires the non-secret trigger gate configuration // used to dispatch normalized Plane webhook events into work item task creation. // Values are trimmed; an incomplete config leaves dispatch disabled (see // planeWebhookDispatch.ready) so the receiver still answers 202 without retries. func (h *Handler) SetPlaneWebhookDispatchConfig(cfg PlaneWebhookDispatchConfig) { h.planeDispatch = planeWebhookDispatch{ workspaceID: strings.TrimSpace(cfg.WorkspaceID), workspaceSlug: strings.TrimSpace(cfg.WorkspaceSlug), backlogStateID: strings.TrimSpace(cfg.BacklogStateID), agentAssigneeID: strings.TrimSpace(cfg.AgentAssigneeID), selfActorID: strings.TrimSpace(cfg.SelfActorID), } } // dispatchPlaneWebhookWorkItem turns a normalized Plane webhook event into a work // item task creation through the registered "plane" provider. It returns // dispatched=true with the created task only when the event was actually handed // to the creator. When the receiver is reachable but dispatch is not configured, // the event targets a different workspace, or the ref cannot be built, it returns // dispatched=false with no error so the caller answers 202 ignored instead of // triggering a provider retry storm. The strict trigger gate (Backlog state + // AGENT assignee + self-actor guard) lives in workitempipeline, so a non-matching // event surfaces as ErrCreationTriggerIgnored, which the caller maps to 202. func (h *Handler) dispatchPlaneWebhookWorkItem(ctx context.Context, event planeWebhookWorkItemEvent) (storage.Task, bool, error) { creator, ok := h.workItemProviders[planeProvider] if !ok || !h.planeDispatch.ready() { return storage.Task{}, false, nil } if h.planeDispatch.workspaceID != "" && event.WorkspaceID != h.planeDispatch.workspaceID { return storage.Task{}, false, nil } ref, err := event.WorkItemRef(h.planeDispatch.workspaceSlug) if err != nil { return storage.Task{}, false, nil } task, err := creator.CreateTaskFromWorkItem(ctx, workitempipeline.CreateTaskInput{ Ref: ref, Trigger: workitempipeline.CreationTrigger{ RequiredStateID: h.planeDispatch.backlogStateID, RequiredAssigneeID: h.planeDispatch.agentAssigneeID, Actor: event.ActorID, SelfActor: h.planeDispatch.selfActorID, }, }) if err != nil { return storage.Task{}, false, err } return task, true, nil } func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) { secret := strings.TrimSpace(h.planeWebhookSecret) if secret == "" { writeError(w, stdhttp.StatusServiceUnavailable, "plane webhook secret is not configured") return } body, err := io.ReadAll(stdhttp.MaxBytesReader(w, r.Body, maxPlaneWebhookBodyBytes)) if err != nil { if errors.As(err, new(*stdhttp.MaxBytesError)) { writeError(w, stdhttp.StatusRequestEntityTooLarge, "webhook body is too large") return } writeError(w, stdhttp.StatusBadRequest, "invalid webhook body") return } signature := strings.TrimSpace(r.Header.Get("X-Plane-Signature")) if signature == "" || !validPlaneWebhookSignature(secret, body, signature) { if h.logger != nil { h.logger.Warn("plane webhook signature rejected", "delivery", r.Header.Get("X-Plane-Delivery"), "event", r.Header.Get("X-Plane-Event"), ) } writeError(w, stdhttp.StatusUnauthorized, "invalid plane webhook signature") return } var payload planeWebhookPayload if err := json.Unmarshal(body, &payload); err != nil { writeError(w, stdhttp.StatusBadRequest, "invalid JSON body") return } event, relevant, normErr := normalizePlaneWebhookPayload(payload) if h.logger != nil { attrs := []any{ "delivery", r.Header.Get("X-Plane-Delivery"), "header_event", r.Header.Get("X-Plane-Event"), "event", payload.Event, "action", payload.Action, "webhook_id", payload.WebhookID, "workspace_id", payload.WorkspaceID, } switch { case !relevant: attrs = append(attrs, "normalized", false, "reason", "irrelevant_event") case normErr != nil: attrs = append(attrs, "normalized", false, "reason", "missing_work_item_ref") default: attrs = append(attrs, "normalized", true, "work_item_id", event.WorkItemID, "project_id", event.ProjectID, "workspace_id_normalized", event.WorkspaceID, ) if event.ChangeField != "" { attrs = append(attrs, "change_field", event.ChangeField) } if event.ActorID != "" { attrs = append(attrs, "actor_id", event.ActorID) } } h.logger.Info("plane webhook received", attrs...) } // Irrelevant events (non work item) and relevant-but-malformed events (missing // ref) are wakeup hints with nothing to dispatch: ack 202 ignored without side // effects so Plane does not retry. if !relevant || normErr != nil { writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"}) return } task, dispatched, err := h.dispatchPlaneWebhookWorkItem(r.Context(), event) if err != nil { // writeServiceError maps ErrCreationTriggerIgnored to 202 ignored, keeping // trigger-gate rejections from becoming provider retries; other errors map // to their service status. h.writeServiceError(w, err) return } if !dispatched { writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"}) return } writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted", "task_id": task.ID}) } func validPlaneWebhookSignature(secret string, body []byte, signature string) bool { expectedMAC := hmac.New(sha256.New, []byte(secret)) _, _ = expectedMAC.Write(body) expected := expectedMAC.Sum(nil) actual, err := hex.DecodeString(signature) if err != nil { return false } return hmac.Equal(actual, expected) }