nomadcode/services/core/internal/http/plane_webhook.go
toki 58dce3d7a3 feat: agent-ops MCP control plane phase updates and core service refinements
- Update roadmap and phase documentation for agent-ops MCP control plane
- Update milestone work-item creation/sync documentation
- Refine core server command and HTTP Plane webhook handling
- Update Plane webhook tests
2026-06-16 11:01:17 +09:00

329 lines
12 KiB
Go

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/workflow"
"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
}
if h.planeDispatch.selfActorID != "" && event.ActorID == h.planeDispatch.selfActorID {
return storage.Task{}, false, workitempipeline.ErrCreationTriggerIgnored
}
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
}
if h.webhookEnqueuer != nil && planeWebhookShouldEnqueue(task.Status) {
queued, err := h.webhookEnqueuer.EnqueueTask(ctx, task.ID)
if err != nil {
if errors.Is(err, workflow.ErrTaskCannotBeEnqueued) {
return task, true, nil
}
return storage.Task{}, false, err
}
task = queued
}
return task, true, nil
}
func planeWebhookShouldEnqueue(status string) bool {
switch workflow.TaskStatus(strings.TrimSpace(status)) {
case workflow.StatusPending, workflow.StatusFailed:
return true
default:
return false
}
}
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)
}