nomadcode/services/core/internal/http/plane_webhook.go
toki f23ba78eba feat: plane webhook intake & protosocket consumer wire readiness
- Add Plane webhook handler for issue events (created, state, assignees)
- Add Plane webhook integration tests with testdata fixtures
- Add Gito Protosocket consumer wire readiness milestone
- Add Plane work item webhook intake milestone
- Add agent-task for plane-work-item-webhook-intake (trigger dispatch, idempotency, live smoke)
- Update service config, router, handlers for Plane webhook endpoints
- Add SOPS env setup script and secrets configuration
- Update agent-ops domain rules and phase roadmap
2026-06-14 20:37:48 +09:00

234 lines
8 KiB
Go

package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
stdhttp "net/http"
"strings"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
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)
}
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...)
}
// The webhook is a wakeup hint only. Trigger dispatch and idempotency are
// handled in later milestones; here we accept all valid signed payloads so
// Plane does not retry while normalization boundaries settle.
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"})
}
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)
}