- Move G07 task artifacts to archive (plan, code review, complete.log) - Refactor main.go to use new webhook router - Update plane webhook handlers with improved error handling - Add config for Plane webhook integration - Update docker-compose for webhook testing - Update plane-dev test documentation
306 lines
11 KiB
Go
306 lines
11 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/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)
|
|
}
|