- 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
234 lines
8 KiB
Go
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)
|
|
}
|