diff --git a/control-plane/internal/handlers/triggers_provider_ingest_test.go b/control-plane/internal/handlers/triggers_provider_ingest_test.go new file mode 100644 index 000000000..644b23df0 --- /dev/null +++ b/control-plane/internal/handlers/triggers_provider_ingest_test.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + "github.com/stretchr/testify/require" + + _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/all" +) + +func TestIngestProviderWebhookFixtures_LinearAndSentry(t *testing.T) { + provider, _, ctx := setupAPIContractTestEnv(t) + h := NewTriggerHandlers(provider, nil, nil) + r := triggerCoverageRouter(h) + + tests := []struct { + name string + sourceName string + triggerID string + secretEnv string + secret string + config json.RawMessage + body []byte + headers map[string]string + expectedType string + expectedIdempotent string + normalizedContains []string + }{ + { + name: "linear issue create", + sourceName: "linear", + triggerID: "linear-ingest-fixture", + secretEnv: "LINEAR_FIXTURE_SECRET", + secret: "linear-fixture-secret", + config: json.RawMessage(`{"tolerance_seconds":60}`), + body: []byte(fmt.Sprintf( + `{"action":"create","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":%d,"webhookId":"linear-hook-1","data":{"id":"issue-1","identifier":"AF-1"}}`, + time.Now().UnixMilli(), + )), + headers: map[string]string{ + "Linear-Delivery": "linear-delivery-1", + "Linear-Event": "Issue", + }, + expectedType: "issue.create", + expectedIdempotent: "linear-delivery-1", + normalizedContains: []string{ + `"delivery":"linear-delivery-1"`, + `"identifier":"AF-1"`, + }, + }, + { + name: "sentry issue created", + sourceName: "sentry", + triggerID: "sentry-ingest-fixture", + secretEnv: "SENTRY_FIXTURE_SECRET", + secret: "sentry-fixture-secret", + config: json.RawMessage(`{}`), + body: []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123","shortId":"ORG-1"}},"actor":{"type":"user","name":"Ada"}}`), + headers: map[string]string{ + "Request-ID": "sentry-request-1", + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": time.Now().UTC().Format(time.RFC3339), + "Sentry-Hook-Signature": "", + "X-Unused-Provider-Test": "kept", + }, + expectedType: "issue.created", + expectedIdempotent: "sentry-request-1", + normalizedContains: []string{ + `"request_id":"sentry-request-1"`, + `"shortId":"ORG-1"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.secretEnv, tt.secret) + trig := &types.Trigger{ + ID: tt.triggerID, + SourceName: tt.sourceName, + Config: tt.config, + SecretEnvVar: tt.secretEnv, + TargetNodeID: "provider-target", + TargetReasoner: "handle_provider_event", + ManagedBy: types.ManagedByUI, + Enabled: true, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + require.NoError(t, provider.CreateTrigger(ctx, trig)) + + req := httptest.NewRequest(http.MethodPost, "/sources/"+tt.triggerID, strings.NewReader(string(tt.body))) + req.Header.Set("Content-Type", "application/json") + for key, value := range tt.headers { + if value != "" { + req.Header.Set(key, value) + } + } + switch tt.sourceName { + case "linear": + req.Header.Set("Linear-Signature", signProviderFixture(tt.body, tt.secret)) + case "sentry": + req.Header.Set("Sentry-Hook-Signature", signProviderFixture(tt.body, tt.secret)) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equalf(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var resp struct { + Status string `json:"status"` + Received int `json:"received"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "ok", resp.Status) + require.Equal(t, 1, resp.Received) + + events, err := provider.ListInboundEvents(ctx, tt.triggerID, 10) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, tt.sourceName, events[0].SourceName) + require.Equal(t, tt.expectedType, events[0].EventType) + require.Equal(t, tt.expectedIdempotent, events[0].IdempotencyKey) + for _, want := range tt.normalizedContains { + require.Contains(t, string(events[0].NormalizedPayload), want) + } + }) + } +} + +func signProviderFixture(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/control-plane/internal/sources/all/all.go b/control-plane/internal/sources/all/all.go index 2588f9a75..6cf10581e 100644 --- a/control-plane/internal/sources/all/all.go +++ b/control-plane/internal/sources/all/all.go @@ -9,6 +9,8 @@ import ( _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/genericbearer" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/generichmac" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/github" + _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/linear" + _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/sentry" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/slack" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/snowflake" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/stripe" diff --git a/control-plane/internal/sources/linear/linear.go b/control-plane/internal/sources/linear/linear.go new file mode 100644 index 000000000..a2bee74bf --- /dev/null +++ b/control-plane/internal/sources/linear/linear.go @@ -0,0 +1,189 @@ +// Package linear implements the Linear webhook Source. +// +// Linear signs deliveries with HMAC-SHA256 over the raw body using the webhook +// signing secret. The hex digest is sent in Linear-Signature, the entity family +// in Linear-Event, and a unique delivery UUID in Linear-Delivery. +package linear + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/sources" +) + +const defaultToleranceSeconds = 60 + +type source struct{} + +func init() { + sources.Register(&source{}) +} + +func (s *source) Name() string { return "linear" } +func (s *source) Kind() sources.Kind { return sources.KindHTTP } +func (s *source) SecretRequired() bool { return true } + +func (s *source) ConfigSchema() json.RawMessage { + return json.RawMessage(`{ + "type":"object", + "properties":{ + "tolerance_seconds":{"type":"integer","minimum":0,"default":60,"description":"Max age of Linear webhookTimestamp before rejection"} + }, + "additionalProperties": false + }`) +} + +func (s *source) Validate(cfg json.RawMessage) error { + if len(cfg) == 0 { + return nil + } + var parsed struct { + ToleranceSeconds *int `json:"tolerance_seconds"` + } + if err := json.Unmarshal(cfg, &parsed); err != nil { + return fmt.Errorf("invalid linear config: %w", err) + } + if parsed.ToleranceSeconds != nil && *parsed.ToleranceSeconds < 0 { + return errors.New("tolerance_seconds must be >= 0") + } + return nil +} + +func (s *source) HandleRequest(ctx context.Context, req *sources.RawRequest, cfg json.RawMessage, secret string) ([]sources.Event, error) { + if secret == "" { + return nil, errors.New("linear: missing webhook signing secret") + } + signature := req.Headers.Get("Linear-Signature") + if signature == "" { + return nil, errors.New("linear: missing Linear-Signature header") + } + if err := verifySignature(req.Body, signature, secret); err != nil { + return nil, err + } + + var payload struct { + Action string `json:"action"` + Type string `json:"type"` + Actor json.RawMessage `json:"actor"` + CreatedAt string `json:"createdAt"` + Data json.RawMessage `json:"data"` + URL string `json:"url"` + UpdatedFrom json.RawMessage `json:"updatedFrom"` + WebhookID string `json:"webhookId"` + WebhookTimestamp int64 `json:"webhookTimestamp"` + } + if err := json.Unmarshal(req.Body, &payload); err != nil { + return nil, fmt.Errorf("linear: invalid event JSON: %w", err) + } + + tolerance := defaultToleranceSeconds + if len(cfg) > 0 { + var parsed struct { + ToleranceSeconds *int `json:"tolerance_seconds"` + } + if err := json.Unmarshal(cfg, &parsed); err == nil && parsed.ToleranceSeconds != nil { + tolerance = *parsed.ToleranceSeconds + } + } + if tolerance > 0 { + if payload.WebhookTimestamp == 0 { + return nil, errors.New("linear: missing webhookTimestamp") + } + sentAt := time.UnixMilli(payload.WebhookTimestamp) + if diff := time.Since(sentAt); diff > time.Duration(tolerance)*time.Second || diff < -time.Duration(tolerance)*time.Second { + return nil, errors.New("linear: webhookTimestamp outside tolerance window") + } + } + + eventType := normalizedEventType(firstNonBlank(payload.Type, req.Headers.Get("Linear-Event")), payload.Action) + normalized, _ := json.Marshal(map[string]any{ + "action": payload.Action, + "type": payload.Type, + "actor": rawOrNil(payload.Actor), + "created_at": payload.CreatedAt, + "data": rawOrNil(payload.Data), + "url": payload.URL, + "updated_from": rawOrNil(payload.UpdatedFrom), + "webhook_id": payload.WebhookID, + "webhook_timestamp": payload.WebhookTimestamp, + "linear": map[string]string{ + "delivery": req.Headers.Get("Linear-Delivery"), + "event": req.Headers.Get("Linear-Event"), + }, + }) + + // Determine idempotency key: use Linear-Delivery if present, otherwise compute hash + idempotencyKey := req.Headers.Get("Linear-Delivery") + if idempotencyKey == "" { + idempotencyKey = deliveryHash(payload.WebhookID, payload.WebhookTimestamp, payload.Type, payload.Action) + } + + return []sources.Event{{ + Type: eventType, + IdempotencyKey: idempotencyKey, + Raw: req.Body, + Normalized: normalized, + }}, nil +} + +func verifySignature(body []byte, header, secret string) error { + got, err := hex.DecodeString(strings.TrimSpace(header)) + if err != nil { + return fmt.Errorf("linear: invalid Linear-Signature header: %w", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + if !hmac.Equal(got, mac.Sum(nil)) { + return errors.New("linear: signature mismatch") + } + return nil +} + +func deliveryHash(webhookID string, ts int64, entityType, action string) string { + h := sha256.New() + h.Write([]byte(webhookID)) + h.Write([]byte{0}) + h.Write([]byte(strconv.FormatInt(ts, 10))) + h.Write([]byte{0}) + h.Write([]byte(entityType)) + h.Write([]byte{0}) + h.Write([]byte(action)) + return hex.EncodeToString(h.Sum(nil)) +} + +func normalizedEventType(entityType, action string) string { + entityType = strings.ToLower(strings.TrimSpace(entityType)) + action = strings.ToLower(strings.TrimSpace(action)) + if entityType == "" { + return action + } + if action == "" { + return entityType + } + return entityType + "." + action +} + +func rawOrNil(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return raw +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/control-plane/internal/sources/linear/linear_test.go b/control-plane/internal/sources/linear/linear_test.go new file mode 100644 index 000000000..d2cc6bc5e --- /dev/null +++ b/control-plane/internal/sources/linear/linear_test.go @@ -0,0 +1,205 @@ +package linear + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/sources" +) + +func sign(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func rawReq(body []byte, headers map[string]string) *sources.RawRequest { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &sources.RawRequest{ + Headers: h, + Body: body, + URL: &url.URL{Path: "/sources/linear"}, + Method: "POST", + } +} + +func TestLinear_VerifiesValidSignature(t *testing.T) { + secret := "linear_secret" + body := []byte(`{"action":"create","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":` + nowMilli() + `,"webhookId":"hook-1","data":{"id":"issue-1"}}`) + req := rawReq(body, map[string]string{ + "Linear-Signature": sign(body, secret), + "Linear-Delivery": "delivery-123", + "Linear-Event": "Issue", + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } + if events[0].Type != "issue.create" { + t.Fatalf("type=%q, want issue.create", events[0].Type) + } + if events[0].IdempotencyKey != "delivery-123" { + t.Fatalf("idempotency=%q", events[0].IdempotencyKey) + } + if !strings.Contains(string(events[0].Normalized), `"delivery":"delivery-123"`) { + t.Fatalf("normalized missing delivery: %s", events[0].Normalized) + } +} + +func TestLinear_RejectsTamperedBody(t *testing.T) { + secret := "linear_secret" + body := []byte(`{"action":"create","type":"Issue","webhookTimestamp":` + nowMilli() + `}`) + tampered := []byte(`{"action":"update","type":"Issue","webhookTimestamp":` + nowMilli() + `}`) + req := rawReq(tampered, map[string]string{"Linear-Signature": sign(body, secret)}) + + _, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err == nil || !strings.Contains(err.Error(), "signature mismatch") { + t.Fatalf("expected signature mismatch, got %v", err) + } +} + +func TestLinear_RejectsStaleTimestamp(t *testing.T) { + secret := "linear_secret" + body := []byte(`{"action":"update","type":"Issue","webhookTimestamp":` + staleMilli() + `}`) + req := rawReq(body, map[string]string{"Linear-Signature": sign(body, secret)}) + + _, err := (&source{}).HandleRequest(context.Background(), req, []byte(`{"tolerance_seconds":60}`), secret) + if err == nil || !strings.Contains(err.Error(), "outside tolerance") { + t.Fatalf("expected tolerance error, got %v", err) + } +} + +func TestLinear_AllowsDisabledTimestampTolerance(t *testing.T) { + secret := "linear_secret" + body := []byte(`{"action":"update","type":"Issue","webhookTimestamp":` + staleMilli() + `}`) + req := rawReq(body, map[string]string{"Linear-Signature": sign(body, secret), "Linear-Delivery": "old"}) + + events, err := (&source{}).HandleRequest(context.Background(), req, []byte(`{"tolerance_seconds":0}`), secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events[0].IdempotencyKey != "old" { + t.Fatalf("idempotency=%q", events[0].IdempotencyKey) + } +} + +func TestLinear_RejectsInvalidConfig(t *testing.T) { + if err := (&source{}).Validate([]byte(`{"tolerance_seconds":-1}`)); err == nil { + t.Fatal("expected invalid config error") + } +} + +func TestLinear_RegistryMetadata(t *testing.T) { + s := &source{} + if s.Name() != "linear" { + t.Fatalf("name=%q", s.Name()) + } + if s.Kind() != sources.KindHTTP { + t.Fatalf("kind=%v", s.Kind()) + } + if !s.SecretRequired() { + t.Fatal("linear should require a secret") + } + if len(s.ConfigSchema()) == 0 { + t.Fatal("linear should expose a config schema") + } +} + +func TestLinear_HashesIdempotencyWhenDeliveryMissing(t *testing.T) { + secret := "linear_secret" + currentTs := nowMilli() + body := []byte(`{"action":"create","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":` + currentTs + `,"webhookId":"hook-123","data":{"id":"issue-1"}}`) + req := rawReq(body, map[string]string{ + "Linear-Signature": sign(body, secret), + // No Linear-Delivery header + "Linear-Event": "Issue", + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } + + // IdempotencyKey should be a 64-char hex string (SHA256 hash) + key := events[0].IdempotencyKey + if len(key) != 64 { + t.Fatalf("idempotency key length=%d, want 64 (hex SHA256)", len(key)) + } + for _, c := range key { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("idempotency key contains non-hex char: %c", c) + } + } + + // Same inputs should produce same key + events2, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("second call: %v", err) + } + if events2[0].IdempotencyKey != events[0].IdempotencyKey { + t.Fatalf("same inputs produced different keys: %q vs %q", events[0].IdempotencyKey, events2[0].IdempotencyKey) + } + + // Different action should produce different key + bodyDiffAction := []byte(`{"action":"update","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":` + currentTs + `,"webhookId":"hook-123","data":{"id":"issue-1"}}`) + reqDiffAction := rawReq(bodyDiffAction, map[string]string{ + "Linear-Signature": sign(bodyDiffAction, secret), + "Linear-Event": "Issue", + }) + eventsDiff, err := (&source{}).HandleRequest(context.Background(), reqDiffAction, nil, secret) + if err != nil { + t.Fatalf("diff action call: %v", err) + } + if eventsDiff[0].IdempotencyKey == events[0].IdempotencyKey { + t.Fatalf("different actions produced same key: %q", events[0].IdempotencyKey) + } +} + +func TestLinear_PreferrsLinearDeliveryHeaderOverHash(t *testing.T) { + secret := "linear_secret" + currentTs := nowMilli() + body := []byte(`{"action":"create","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":` + currentTs + `,"webhookId":"hook-123","data":{"id":"issue-1"}}`) + req := rawReq(body, map[string]string{ + "Linear-Signature": sign(body, secret), + "Linear-Delivery": "explicit-delivery-uuid", + "Linear-Event": "Issue", + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events[0].IdempotencyKey != "explicit-delivery-uuid" { + t.Fatalf("idempotency=%q, want explicit-delivery-uuid", events[0].IdempotencyKey) + } +} + +func nowMilli() string { + return strconvFormat(time.Now().UnixMilli()) +} + +func staleMilli() string { + return strconvFormat(time.Now().Add(-5 * time.Minute).UnixMilli()) +} + +func strconvFormat(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/control-plane/internal/sources/sentry/sentry.go b/control-plane/internal/sources/sentry/sentry.go new file mode 100644 index 000000000..a3f986766 --- /dev/null +++ b/control-plane/internal/sources/sentry/sentry.go @@ -0,0 +1,183 @@ +// Package sentry implements the Sentry integration-platform webhook Source. +// +// Sentry sends JSON webhooks with Sentry-Hook-Resource, Request-ID, +// Sentry-Hook-Timestamp, and Sentry-Hook-Signature headers. The signature is +// an HMAC-SHA256 digest computed with the integration Client Secret. +package sentry + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/sources" +) + +const defaultToleranceSeconds = 300 + +type source struct{} + +func init() { + sources.Register(&source{}) +} + +func (s *source) Name() string { return "sentry" } +func (s *source) Kind() sources.Kind { return sources.KindHTTP } +func (s *source) SecretRequired() bool { return true } + +func (s *source) ConfigSchema() json.RawMessage { + return json.RawMessage(`{ + "type":"object", + "properties":{ + "tolerance_seconds":{"type":"integer","minimum":0,"default":300,"description":"Max age of Sentry-Hook-Timestamp before rejection. Set to 0 to disable."} + }, + "additionalProperties": false + }`) +} + +func (s *source) Validate(cfg json.RawMessage) error { + if len(cfg) == 0 { + return nil + } + var parsed struct { + ToleranceSeconds *int `json:"tolerance_seconds"` + } + if err := json.Unmarshal(cfg, &parsed); err != nil { + return fmt.Errorf("invalid sentry config: %w", err) + } + if parsed.ToleranceSeconds != nil && *parsed.ToleranceSeconds < 0 { + return errors.New("tolerance_seconds must be >= 0") + } + return nil +} + +func (s *source) HandleRequest(ctx context.Context, req *sources.RawRequest, cfg json.RawMessage, secret string) ([]sources.Event, error) { + if secret == "" { + return nil, errors.New("sentry: missing client secret") + } + signature := req.Headers.Get("Sentry-Hook-Signature") + if signature == "" { + return nil, errors.New("sentry: missing Sentry-Hook-Signature header") + } + if err := verifySignature(req.Body, signature, secret); err != nil { + return nil, err + } + + tolerance := defaultToleranceSeconds + if len(cfg) > 0 { + var parsed struct { + ToleranceSeconds *int `json:"tolerance_seconds"` + } + if err := json.Unmarshal(cfg, &parsed); err == nil && parsed.ToleranceSeconds != nil { + tolerance = *parsed.ToleranceSeconds + } + } + if tolerance > 0 { + timestamp := req.Headers.Get("Sentry-Hook-Timestamp") + if timestamp == "" { + return nil, errors.New("sentry: missing or invalid Sentry-Hook-Timestamp") + } + parsedTime, err := parseTimestamp(timestamp) + if err != nil { + return nil, errors.New("sentry: missing or invalid Sentry-Hook-Timestamp") + } + if diff := time.Since(parsedTime); diff > time.Duration(tolerance)*time.Second || diff < -time.Duration(tolerance)*time.Second { + return nil, errors.New("sentry: Sentry-Hook-Timestamp outside tolerance window") + } + } + + var payload struct { + Action string `json:"action"` + Installation json.RawMessage `json:"installation"` + Data json.RawMessage `json:"data"` + Actor json.RawMessage `json:"actor"` + } + if err := json.Unmarshal(req.Body, &payload); err != nil { + return nil, fmt.Errorf("sentry: invalid event JSON: %w", err) + } + resource := strings.ToLower(strings.TrimSpace(req.Headers.Get("Sentry-Hook-Resource"))) + eventType := resource + if action := strings.ToLower(strings.TrimSpace(payload.Action)); action != "" { + if eventType == "" { + eventType = action + } else { + eventType += "." + action + } + } + requestID := req.Headers.Get("Request-ID") + if requestID == "" { + requestID = bodyDigest(resource, req.Body) + } + normalized, _ := json.Marshal(map[string]any{ + "action": payload.Action, + "resource": resource, + "installation": rawOrNil(payload.Installation), + "data": rawOrNil(payload.Data), + "actor": rawOrNil(payload.Actor), + "sentry": map[string]string{ + "request_id": requestID, + "timestamp": req.Headers.Get("Sentry-Hook-Timestamp"), + "resource": req.Headers.Get("Sentry-Hook-Resource"), + }, + }) + + return []sources.Event{{ + Type: eventType, + IdempotencyKey: requestID, + Raw: req.Body, + Normalized: normalized, + }}, nil +} + +func verifySignature(body []byte, header, secret string) error { + got, err := hex.DecodeString(strings.TrimSpace(header)) + if err != nil { + return fmt.Errorf("sentry: invalid Sentry-Hook-Signature header: %w", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + if !hmac.Equal(got, mac.Sum(nil)) { + return errors.New("sentry: signature mismatch") + } + return nil +} + +func parseTimestamp(ts string) (time.Time, error) { + // Try RFC3339 first + if t, err := time.Parse(time.RFC3339, ts); err == nil { + return t, nil + } + + // Try Unix seconds/milliseconds as string + if secs, err := strconv.ParseInt(ts, 10, 64); err == nil { + // Check if it's milliseconds (> 10^12) + if secs > 1e12 { + return time.UnixMilli(secs), nil + } + return time.Unix(secs, 0), nil + } + + return time.Time{}, errors.New("invalid timestamp format") +} + +func bodyDigest(resource string, body []byte) string { + mac := sha256.New() + mac.Write([]byte(resource)) + mac.Write([]byte{0}) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func rawOrNil(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return raw +} diff --git a/control-plane/internal/sources/sentry/sentry_test.go b/control-plane/internal/sources/sentry/sentry_test.go new file mode 100644 index 000000000..8b9a856a5 --- /dev/null +++ b/control-plane/internal/sources/sentry/sentry_test.go @@ -0,0 +1,200 @@ +package sentry + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/sources" +) + +func sign(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func rawReq(body []byte, headers map[string]string) *sources.RawRequest { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &sources.RawRequest{ + Headers: h, + Body: body, + URL: &url.URL{Path: "/sources/sentry"}, + Method: "POST", + } +} + +func TestSentry_VerifiesValidSignature(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123"}},"actor":{"type":"user","name":"Ada"}}`) + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": time.Now().UTC().Format(time.RFC3339), + "Request-ID": "req-123", + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } + if events[0].Type != "issue.created" { + t.Fatalf("type=%q, want issue.created", events[0].Type) + } + if events[0].IdempotencyKey != "req-123" { + t.Fatalf("idempotency=%q", events[0].IdempotencyKey) + } + if !strings.Contains(string(events[0].Normalized), `"resource":"issue"`) { + t.Fatalf("normalized missing resource: %s", events[0].Normalized) + } +} + +func TestSentry_DerivesIdempotencyWhenRequestIDMissing(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"resolved"}`) + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": time.Now().UTC().Format(time.RFC3339), + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events[0].IdempotencyKey) != 64 { + t.Fatalf("expected sha256 idempotency fallback, got %q", events[0].IdempotencyKey) + } +} + +func TestSentry_RejectsTamperedBody(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created"}`) + tampered := []byte(`{"action":"resolved"}`) + req := rawReq(tampered, map[string]string{"Sentry-Hook-Signature": sign(body, secret)}) + + _, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err == nil || !strings.Contains(err.Error(), "signature mismatch") { + t.Fatalf("expected signature mismatch, got %v", err) + } +} + +func TestSentry_RejectsMissingSecret(t *testing.T) { + body := []byte(`{"action":"created"}`) + req := rawReq(body, map[string]string{"Sentry-Hook-Signature": sign(body, "x")}) + if _, err := (&source{}).HandleRequest(context.Background(), req, nil, ""); err == nil { + t.Fatal("expected missing secret error") + } +} + +func TestSentry_RegistryMetadata(t *testing.T) { + s := &source{} + if s.Name() != "sentry" { + t.Fatalf("name=%q", s.Name()) + } + if s.Kind() != sources.KindHTTP { + t.Fatalf("kind=%v", s.Kind()) + } + if !s.SecretRequired() { + t.Fatal("sentry should require a secret") + } + if len(s.ConfigSchema()) == 0 { + t.Fatal("sentry should expose a config schema") + } +} + +func TestSentry_RejectsStaleTimestamp(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123"}},"actor":{"type":"user","name":"Ada"}}`) + + // Set timestamp to 10 minutes ago + staleTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339) + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": staleTime, + "Request-ID": "req-123", + }) + + config := []byte(`{"tolerance_seconds":300}`) + _, err := (&source{}).HandleRequest(context.Background(), req, config, secret) + if err == nil || !strings.Contains(err.Error(), "outside tolerance") { + t.Fatalf("expected timestamp outside tolerance error, got %v", err) + } +} + +func TestSentry_AllowsDisabledTolerance(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123"}},"actor":{"type":"user","name":"Ada"}}`) + + // Set timestamp to 10 minutes ago + staleTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339) + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": staleTime, + "Request-ID": "req-123", + }) + + // tolerance_seconds = 0 disables the check + config := []byte(`{"tolerance_seconds":0}`) + events, err := (&source{}).HandleRequest(context.Background(), req, config, secret) + if err != nil { + t.Fatalf("unexpected error with disabled tolerance: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } +} + +func TestSentry_RejectsMissingTimestamp(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123"}},"actor":{"type":"user","name":"Ada"}}`) + + // No Sentry-Hook-Timestamp header, default tolerance applies + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Request-ID": "req-123", + }) + + _, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err == nil || !strings.Contains(err.Error(), "missing or invalid") { + t.Fatalf("expected missing timestamp error, got %v", err) + } +} + +func TestSentry_AcceptsUnixSecondsTimestamp(t *testing.T) { + secret := "sentry_client_secret" + body := []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123"}},"actor":{"type":"user","name":"Ada"}}`) + + // Set timestamp to current time as unix seconds string + unixSecsStr := fmt.Sprintf("%d", time.Now().Unix()) + req := rawReq(body, map[string]string{ + "Sentry-Hook-Signature": sign(body, secret), + "Sentry-Hook-Resource": "issue", + "Sentry-Hook-Timestamp": unixSecsStr, + "Request-ID": "req-123", + }) + + events, err := (&source{}).HandleRequest(context.Background(), req, nil, secret) + if err != nil { + t.Fatalf("unexpected error with unix seconds timestamp: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } +} diff --git a/control-plane/web/client/package-lock.json b/control-plane/web/client/package-lock.json index 1b5fa5f71..e23df7c3d 100644 --- a/control-plane/web/client/package-lock.json +++ b/control-plane/web/client/package-lock.json @@ -53,7 +53,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.1.12", + "react-icons": "^5.6.0" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -11244,6 +11245,15 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } } } } diff --git a/control-plane/web/client/package.json b/control-plane/web/client/package.json index 6158e3069..570173ed6 100644 --- a/control-plane/web/client/package.json +++ b/control-plane/web/client/package.json @@ -50,6 +50,7 @@ "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.16.0", "recharts": "^2.15.4", diff --git a/control-plane/web/client/pnpm-lock.yaml b/control-plane/web/client/pnpm-lock.yaml index 858447141..aab383bf0 100644 --- a/control-plane/web/client/pnpm-lock.yaml +++ b/control-plane/web/client/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.1(react@19.1.1) + react-icons: + specifier: ^5.6.0 + version: 5.6.0(react@19.1.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.1.13)(react@19.1.1) @@ -3098,6 +3101,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + peerDependencies: + react: '*' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6763,6 +6771,10 @@ snapshots: dependencies: react: 19.1.1 + react-icons@5.6.0(react@19.1.1): + dependencies: + react: 19.1.1 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx b/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx index 66cabfa81..e9c66ce56 100644 --- a/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx +++ b/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx @@ -109,6 +109,18 @@ const SOURCE_HINTS: Record = { 2, ), }, + linear: { + reasoner: "handle_linear_event", + eventTypes: "issue.create, issue.update, comment.create", + secretEnv: "LINEAR_WEBHOOK_SECRET", + configJson: '{"tolerance_seconds": 60}', + }, + sentry: { + reasoner: "handle_sentry_event", + eventTypes: "issue.created, event_alert.triggered, error.created", + secretEnv: "SENTRY_CLIENT_SECRET", + configJson: '{"tolerance_seconds": 300}', + }, generic_hmac: { reasoner: "handle_event", eventTypes: "", @@ -134,6 +146,12 @@ function descriptionFor(sourceName: string, isLoopSource: boolean) { if (sourceName === "databricks") { return "Bind a Databricks notification destination to a reasoner. The control plane verifies the webhook secret and dispatches each normalized Databricks event to the selected node."; } + if (sourceName === "linear") { + return "Bind Linear webhooks to a reasoner. The control plane verifies Linear-Signature with the signing secret before dispatching issue, comment, project, and team events."; + } + if (sourceName === "sentry") { + return "Bind Sentry integration-platform webhooks to a reasoner. The control plane verifies Sentry-Hook-Signature with the integration client secret before dispatching events."; + } if (isLoopSource) { return "Bind a control-plane loop source to a reasoner. The source emits events from the schedule or polling config below."; } @@ -315,12 +333,17 @@ export function NewTriggerDialog({ {showEventTypes ? (
- + setEventTypes(e.target.value)} placeholder={hints.eventTypes || "(blank for all events)"} /> +

+ Leave blank to accept every event this source emits. Add a + comma-separated list only when this trigger should narrow to + specific event types; examples are not exhaustive. +

) : null} diff --git a/control-plane/web/client/src/components/triggers/SourceIcon.tsx b/control-plane/web/client/src/components/triggers/SourceIcon.tsx index 25e22c826..0f79db5c5 100644 --- a/control-plane/web/client/src/components/triggers/SourceIcon.tsx +++ b/control-plane/web/client/src/components/triggers/SourceIcon.tsx @@ -1,7 +1,15 @@ import type { ComponentType, SVGProps } from "react"; +import { + SiDatabricks, + SiGithub, + SiLinear, + SiSentry, + SiSlack, + SiSnowflake, + SiStripe, +} from "react-icons/si"; import { Clock, - GithubLogo, Key, Lock, Webhook, @@ -10,89 +18,20 @@ import { cn } from "@/lib/utils"; type IconLike = ComponentType<{ className?: string } & SVGProps>; -function StripeGlyph({ className }: { className?: string }) { - return ( - - - - ); -} - -function SlackGlyph({ className }: { className?: string }) { - return ( - - - - ); -} - -function SnowflakeGlyph({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - ); -} - -function DatabricksGlyph({ className }: { className?: string }) { - return ( - - - - - - ); -} - const SOURCE_ICON_MAP: Record = { - stripe: StripeGlyph as IconLike, - github: GithubLogo as IconLike, - slack: SlackGlyph as IconLike, - snowflake: SnowflakeGlyph as IconLike, - databricks: DatabricksGlyph as IconLike, + stripe: SiStripe as IconLike, + github: SiGithub as IconLike, + slack: SiSlack as IconLike, + snowflake: SiSnowflake as IconLike, + databricks: SiDatabricks as IconLike, + linear: SiLinear as IconLike, + sentry: SiSentry as IconLike, cron: Clock as IconLike, generic_hmac: Lock as IconLike, generic_bearer: Key as IconLike, }; -export function getSourceIcon(sourceName: string): IconLike { +function getSourceIcon(sourceName: string): IconLike { const key = sourceName.toLowerCase(); return SOURCE_ICON_MAP[key] ?? (Webhook as IconLike); } diff --git a/control-plane/web/client/src/components/triggers/databricks-trigger-ui.test.tsx b/control-plane/web/client/src/components/triggers/databricks-trigger-ui.test.tsx index 6d0cf93ae..4d8ab6735 100644 --- a/control-plane/web/client/src/components/triggers/databricks-trigger-ui.test.tsx +++ b/control-plane/web/client/src/components/triggers/databricks-trigger-ui.test.tsx @@ -9,7 +9,7 @@ describe("Databricks trigger UI", () => { const { container } = render(); expect(container.querySelector("svg")).not.toBeNull(); - expect(container.querySelectorAll("path")).toHaveLength(3); + expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(1); }); it("shows Databricks trigger guidance and defaults", () => { diff --git a/control-plane/web/client/src/pages/IntegrationsPage.tsx b/control-plane/web/client/src/pages/IntegrationsPage.tsx index 8d5048a29..7b5f72d62 100644 --- a/control-plane/web/client/src/pages/IntegrationsPage.tsx +++ b/control-plane/web/client/src/pages/IntegrationsPage.tsx @@ -98,6 +98,20 @@ const SOURCE_META: Record = { "Model Serving", ], }, + linear: { + display: "Linear", + category: "Provider", + description: + "Issue, comment, project, and team events signed with Linear-Signature HMAC.", + highlights: ["issue.create", "issue.update", "comment.create"], + }, + sentry: { + display: "Sentry", + category: "Provider", + description: + "Issue, alert, error, and comment webhooks signed with the Sentry integration client secret.", + highlights: ["issue.created", "event_alert.triggered", "error.created"], + }, generic_hmac: { display: "Generic HMAC", category: "Generic", diff --git a/control-plane/web/client/src/test/pages/triggers-pages.test.tsx b/control-plane/web/client/src/test/pages/triggers-pages.test.tsx index 209cadb34..e3379e6c3 100644 --- a/control-plane/web/client/src/test/pages/triggers-pages.test.tsx +++ b/control-plane/web/client/src/test/pages/triggers-pages.test.tsx @@ -1,6 +1,5 @@ -// @ts-nocheck import * as React from "react"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes } from "react-router-dom"; @@ -27,6 +26,18 @@ const sources = [ secret_required: true, config_schema: { mode: "string", account_url: "string" }, }, + { + name: "linear", + kind: "http", + secret_required: true, + config_schema: { tolerance_seconds: "integer" }, + }, + { + name: "sentry", + kind: "http", + secret_required: true, + config_schema: { type: "object" }, + }, { name: "generic_bearer", kind: "http", @@ -202,7 +213,7 @@ describe("trigger management pages", () => { const createCall = fetchMock.mock.calls.find( ([url, init]) => String(url).endsWith("/api/v1/triggers") && init?.method === "POST", ); - expect(createCall).toBeTruthy(); + if (!createCall) throw new Error("expected create trigger call"); expect(JSON.parse(createCall[1].body as string)).toMatchObject({ source_name: "generic_bearer", target_node_id: "ops-agent", @@ -232,12 +243,45 @@ describe("trigger management pages", () => { expect(await screen.findByRole("heading", { name: "New trigger" })).toBeInTheDocument(); expect(screen.getByText(/Snowflake event table poller/i)).toBeInTheDocument(); expect(screen.getAllByText(/programmatic access token/i).length).toBeGreaterThan(0); - expect(screen.queryByText(/Event types/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Event filters/i)).not.toBeInTheDocument(); expect(screen.getByPlaceholderText("handle_snowflake_event")).toBeInTheDocument(); expect(screen.getByPlaceholderText("SNOWFLAKE_PAT")).toBeInTheDocument(); expect(screen.getByDisplayValue(/"mode": "event_table_poll"/i)).toBeInTheDocument(); }); + it("opens Linear and Sentry create flows with provider defaults", async () => { + const user = userEvent.setup(); + renderWithRouter(null, "/integrations"); + + expect(await screen.findByText("Linear")).toBeInTheDocument(); + expect(screen.getByText("Sentry")).toBeInTheDocument(); + expect(screen.getByText("issue.create")).toBeInTheDocument(); + expect(screen.getByText("issue.created")).toBeInTheDocument(); + + const linearConnect = screen + .getAllByRole("button", { name: /Connect/i }) + .find((button) => button.closest(".group")?.textContent?.includes("Linear")); + expect(linearConnect).toBeTruthy(); + await user.click(linearConnect!); + expect((await screen.findAllByText(/Linear-Signature/i)).length).toBeGreaterThan(0); + expect(screen.getByText("Event filters (optional)")).toBeInTheDocument(); + expect(screen.getByText(/Leave blank to accept every event/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText("handle_linear_event")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("LINEAR_WEBHOOK_SECRET")).toBeInTheDocument(); + expect(screen.getByDisplayValue(/"tolerance_seconds": 60/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + const sentryConnect = screen + .getAllByRole("button", { name: /Connect/i }) + .find((button) => button.closest(".group")?.textContent?.includes("Sentry")); + expect(sentryConnect).toBeTruthy(); + await user.click(sentryConnect!); + expect((await screen.findAllByText(/Sentry-Hook-Signature/i)).length).toBeGreaterThan(0); + expect(screen.getByPlaceholderText("handle_sentry_event")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("SENTRY_CLIENT_SECRET")).toBeInTheDocument(); + }); + it("filters active triggers, opens event evidence, and exercises update/copy/replay paths", async () => { const user = userEvent.setup(); renderWithRouter(null, "/triggers"); @@ -295,6 +339,7 @@ describe("trigger management pages", () => { const updateCall = fetchMock.mock.calls.find( ([url, init]) => String(url).includes("/api/v1/triggers/trig_stripe_123456") && init?.method === "PUT", ); + if (!updateCall) throw new Error("expected update trigger call"); expect(JSON.parse(updateCall[1].body as string)).toEqual({ enabled: false }); }); }); diff --git a/docs/integrations/README.md b/docs/integrations/README.md index bbdce9ec6..e8b9cbb65 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -7,11 +7,22 @@ Each pack can include: - control-plane trigger source contracts, - installable capability node manifests, - node capability contracts, -- prompt configuration defaults for `.ai` based reasoners, +- optional provider-native AI capability defaults when a pack includes them, - implementation notes for provider-specific runtime code. Current first-party integration design: - [Snowflake](snowflake.md) +- [Linear](linear.md) +- [Sentry](sentry.md) The canonical pack files live under `integrations//`. + +## UI Preview + +![Integrations catalog with Linear and Sentry](../screenshots/integrations-catalog-linear-sentry.png) + +Provider setup dialogs are linked from the provider pages: + +- [Linear connect dialog](linear.md#ui) +- [Sentry connect dialog](sentry.md#ui) diff --git a/docs/integrations/linear.md b/docs/integrations/linear.md new file mode 100644 index 000000000..e057071d6 --- /dev/null +++ b/docs/integrations/linear.md @@ -0,0 +1,51 @@ +# Linear Integration + +Linear is a first-party OSS integration in this repo. It includes a signed webhook source and a deterministic capability node. + +## Control-Plane Source + +Use source `linear` for Linear webhooks. Configure the Linear webhook URL to the AgentField trigger ingest URL and store the Linear signing secret in the control-plane env var referenced by the trigger, usually `LINEAR_WEBHOOK_SECRET`. + +AgentField verifies `Linear-Signature`, rejects stale `webhookTimestamp` values by default, and emits event types such as `issue.create`, `issue.update`, and `comment.create`. + +## UI + +![Linear connect dialog](../screenshots/linear-connect-dialog.png) + +The dialog shows common event-type examples, the signing-secret env var, and the timestamp tolerance config. Event filters are optional; leave them blank when one trigger should accept every Linear event, including future event types Linear may add. + +## Capability Node + +Deploy the node when agents need Linear API operations: + +- `get_issue` with `{ "id": "AF-123" }` +- `list_issues` with optional `{ "limit": 25 }` +- `create_issue` with `{ "input": { "teamId": "...", "title": "..." } }` +- `update_issue` with `{ "id": "...", "input": { "stateId": "..." } }` +- `comment_issue` with `{ "issue_id": "...", "body": "..." }` +- `list_teams` and `list_projects` for discovery + +The node calls Linear GraphQL with `LINEAR_API_KEY`. It does not include triage, prioritization, or planning logic; keep that policy in your application layer. + +## DX Path + +1. Set `LINEAR_WEBHOOK_SECRET` on the control-plane process. +2. Create a `linear` trigger in the UI or API and point Linear webhooks at `/sources/`. +3. Set `LINEAR_API_KEY` for the node when agents need read/write Linear API calls. +4. Run `integrations/linear/node`; set `LINEAR_NODE_PUBLIC_URL` when the control plane reaches it through a tunnel or container hostname. + +For local development without a Linear account, set `LINEAR_API_URL` to a mock GraphQL server. The repo tests cover signature ingest and GraphQL client behavior with local fixtures. + +## Real-Provider E2E Checklist + +- Create a Linear webhook using the AgentField ingest URL. +- Send a real Linear issue or comment event and confirm the inbound event is persisted as `issue.*` or `comment.*`. +- Launch the Linear node with `LINEAR_API_KEY` and call `linear-prod.health`. +- Call at least one read capability, such as `get_issue`, and one safe write capability, such as `comment_issue` on a test issue. + +## Source of Truth + +- Pack: `integrations/linear/agentfield-package.yaml` +- Source contract: `integrations/linear/contracts/trigger-source.yaml` +- Capability contract: `integrations/linear/contracts/capabilities.yaml` +- Node: `integrations/linear/node/` diff --git a/docs/integrations/sentry.md b/docs/integrations/sentry.md new file mode 100644 index 000000000..c4336bcfa --- /dev/null +++ b/docs/integrations/sentry.md @@ -0,0 +1,65 @@ +# Sentry Integration + +Sentry is a first-party OSS integration in this repo. It includes an integration-platform webhook source and a deterministic capability node. + +## Control-Plane Source + +Use source `sentry` for Sentry webhooks. Configure the Sentry integration webhook URL to the AgentField trigger ingest URL and store the Sentry integration client secret in the control-plane env var referenced by the trigger, usually `SENTRY_CLIENT_SECRET`. + +AgentField verifies `Sentry-Hook-Signature` and emits event types such as `issue.created`, `event_alert.triggered`, `metric_alert.triggered`, and `error.created`. + +## Region / Base URL + +Sentry's API base URL depends on your organization's data-storage region: + +| Region | `SENTRY_BASE_URL` | +|--------|-------------------| +| Legacy US-only (default) | `https://sentry.io` | +| US region | `https://us.sentry.io` | +| **EU region (mandatory)** | `https://de.sentry.io` | +| Self-hosted Sentry | Your install's hostname | + +**EU customers must set `SENTRY_BASE_URL` explicitly.** The default `https://sentry.io` will return 401/403 for EU-region orgs with no clear hint that the region is the cause. See [Sentry data storage location](https://docs.sentry.io/organization/data-storage-location/). + +## UI + +![Sentry connect dialog](../screenshots/sentry-connect-dialog.png) + +The dialog shows common issue, alert, and error event-type examples plus the client-secret env var. Event filters are optional; leave them blank when one trigger should accept every Sentry webhook resource, including future event types Sentry may add. + +## Capability Node + +Deploy the node when agents need Sentry API operations: + +- `list_issues` with `{ "project": "web", "query": "is:unresolved" }` +- `get_issue` with `{ "issue_id": "123" }` +- `list_issue_events` with `{ "issue_id": "123" }` +- `get_event` with `{ "issue_id": "123", "event_id": "abc" }` +- `resolve_issue` with `{ "issue_id": "123" }` +- `assign_issue` with `{ "issue_id": "123", "assignee": "user:alice@example.com" }` +- `update_issue` for explicit Sentry issue fields + +The node calls the Sentry REST API with `SENTRY_AUTH_TOKEN` and `SENTRY_ORG`. It only wraps explicit Sentry issue and event API operations. + +## DX Path + +1. Set `SENTRY_CLIENT_SECRET` on the control-plane process. +2. Create a `sentry` trigger in the UI or API and point the Sentry integration webhook at `/sources/`. +3. Set `SENTRY_AUTH_TOKEN` and `SENTRY_ORG` for the node when agents need Sentry API calls. +4. Run `integrations/sentry/node`; set `SENTRY_NODE_PUBLIC_URL` when the control plane reaches it through a tunnel or container hostname. + +For local development without a Sentry account, set `SENTRY_BASE_URL` to a mock Sentry API server. The repo tests cover signature ingest and REST client behavior with local fixtures. + +## Real-Provider E2E Checklist + +- Create a Sentry internal integration with webhooks enabled. +- Send a real issue, alert, or error webhook and confirm the inbound event is persisted as `issue.*`, `event_alert.*`, or `error.*`. +- Launch the Sentry node with `SENTRY_AUTH_TOKEN` and `SENTRY_ORG`, then call `sentry-prod.health`. +- Call at least one read capability, such as `get_issue`, and one safe write capability, such as `assign_issue` or `resolve_issue` on a test issue. + +## Source of Truth + +- Pack: `integrations/sentry/agentfield-package.yaml` +- Source contract: `integrations/sentry/contracts/trigger-source.yaml` +- Capability contract: `integrations/sentry/contracts/capabilities.yaml` +- Node: `integrations/sentry/node/` diff --git a/docs/screenshots/integrations-catalog-linear-sentry.png b/docs/screenshots/integrations-catalog-linear-sentry.png new file mode 100644 index 000000000..05df649ff Binary files /dev/null and b/docs/screenshots/integrations-catalog-linear-sentry.png differ diff --git a/docs/screenshots/linear-connect-dialog.png b/docs/screenshots/linear-connect-dialog.png new file mode 100644 index 000000000..67b834d4c Binary files /dev/null and b/docs/screenshots/linear-connect-dialog.png differ diff --git a/docs/screenshots/sentry-connect-dialog.png b/docs/screenshots/sentry-connect-dialog.png new file mode 100644 index 000000000..95831f6f1 Binary files /dev/null and b/docs/screenshots/sentry-connect-dialog.png differ diff --git a/integrations/linear/README.md b/integrations/linear/README.md new file mode 100644 index 000000000..db2f2f7d5 --- /dev/null +++ b/integrations/linear/README.md @@ -0,0 +1,33 @@ +# Linear Integration + +This pack ships a first-party Linear webhook source plus a deterministic Go capability node. + +Use the source when Linear should start an AgentField run from issue, comment, project, team, or cycle events. Use the node when an agent needs bounded Linear API operations such as reading an issue, creating a follow-up issue, or adding a comment. + +## Trigger Source + +- Source name: `linear` +- Kind: HTTP webhook +- Secret: `LINEAR_WEBHOOK_SECRET` +- Signature: `Linear-Signature` HMAC-SHA256 over the raw body +- Idempotency: `Linear-Delivery` +- Event type: `.`, lowercased, for example `issue.create` + +## Capability Node + +Required env: + +```bash +export LINEAR_API_KEY=lin_api_... +``` + +Useful capabilities: + +- `get_issue`: read an issue by UUID or identifier. +- `list_issues`: list recent issues visible to the token. +- `create_issue`: pass Linear `IssueCreateInput` fields. +- `update_issue`: pass Linear `IssueUpdateInput` fields. +- `comment_issue`: add a comment to an issue. +- `list_teams` and `list_projects`: discover IDs before writes. + +These are provider API calls. They do not perform triage, prioritization, or planning. diff --git a/integrations/linear/agentfield-package.yaml b/integrations/linear/agentfield-package.yaml new file mode 100644 index 000000000..40c1b0d79 --- /dev/null +++ b/integrations/linear/agentfield-package.yaml @@ -0,0 +1,64 @@ +name: linear +version: 0.1.0 +description: Linear trigger source and deterministic capability node for issue, team, and project workflows. +author: AgentField +type: agent_node +main: node/linear-node + +agent_node: + node_id: linear-prod + default_port: 8013 + +dependencies: + python: [] + system: [] + +capabilities: + skills: + - name: health + description: Confirm Linear API connectivity for the configured token. + - name: get_issue + description: Read one Linear issue by UUID or identifier. + - name: list_issues + description: List recent Linear issues visible to the token. + - name: create_issue + description: Create a Linear issue from IssueCreateInput fields. + - name: update_issue + description: Update a Linear issue from IssueUpdateInput fields. + - name: comment_issue + description: Add a comment to a Linear issue. + - name: list_teams + description: List Linear teams visible to the token. + - name: list_projects + description: List Linear projects visible to the token. + +runtime: + auto_port: true + heartbeat_interval: 30 + dev_mode: false + +environment: + AGENTFIELD_NODE_ID: linear-prod + AGENTFIELD_SERVER: http://localhost:8080 + +user_environment: + required: + - name: LINEAR_API_KEY + description: Linear API key used for GraphQL API calls. + type: secret + optional: + - name: LINEAR_API_URL + description: Override GraphQL endpoint for tests or Linear-compatible mocks. + type: string + default: https://api.linear.app/graphql + - name: LINEAR_WEBHOOK_SECRET + description: Signing secret used by the control-plane linear source. + type: secret + +metadata: + created_at: "2026-06-15" + sdk_version: ">=0.1.92" + language: go + platform: linear + integration_kind: trigger_source_and_capability_node + control_plane_source: built_in diff --git a/integrations/linear/contracts/capabilities.yaml b/integrations/linear/contracts/capabilities.yaml new file mode 100644 index 000000000..be8655b0b --- /dev/null +++ b/integrations/linear/contracts/capabilities.yaml @@ -0,0 +1,66 @@ +version: 2026-06-15-v1 +node: + default_id: linear-prod + tags: + - linear + - issue-tracking + - project-management + +principles: + - Capabilities are deterministic Linear API operations, not triage or planning workflows. + - Writes require explicit input objects that map to Linear GraphQL mutation inputs. + - Responses preserve provider IDs, URLs, and timestamps so downstream reasoners can cite the source object. + +skills: + health: + input: {} + output: + status: string + viewer: object + + get_issue: + input: + id: string + output: + issue: object + + list_issues: + input: + limit: optional integer + output: + issues: list + + create_issue: + input: + input: object + output: + success: boolean + issue: object + + update_issue: + input: + id: string + input: object + output: + success: boolean + issue: object + + comment_issue: + input: + issue_id: string + body: string + output: + success: boolean + comment: object + + list_teams: + input: + limit: optional integer + output: + teams: list + + list_projects: + input: + limit: optional integer + output: + projects: list diff --git a/integrations/linear/contracts/trigger-source.yaml b/integrations/linear/contracts/trigger-source.yaml new file mode 100644 index 000000000..f57cb9707 --- /dev/null +++ b/integrations/linear/contracts/trigger-source.yaml @@ -0,0 +1,47 @@ +version: 2026-06-15-v1 +source: + name: linear + kind: http + secret_required: true + +signature: + header: Linear-Signature + algorithm: HMAC-SHA256 over raw request body + secret: Linear webhook signing secret + +headers: + delivery_id: Linear-Delivery + event_family: Linear-Event + +config_schema: + tolerance_seconds: + type: integer + default: 60 + minimum: 0 + +normalized_event: + event_type: lower(type) + "." + lower(action) + idempotency_key: Linear-Delivery, falling back to webhookId + fields: + action: string + type: string + actor: object + created_at: string + data: object + url: string + updated_from: object + webhook_id: string + webhook_timestamp: integer + linear: + delivery: string + event: string + +event_contract: + examples: + - issue.create + - issue.update + - comment.create + - project.update + - cycle.update + replay: + behavior: Replays reuse the persisted normalized event and do not re-check Linear. diff --git a/integrations/linear/node/.env.example b/integrations/linear/node/.env.example new file mode 100644 index 000000000..0ebfff7db --- /dev/null +++ b/integrations/linear/node/.env.example @@ -0,0 +1,8 @@ +AGENTFIELD_NODE_ID=linear-dev +AGENTFIELD_SERVER=http://localhost:8080 +LINEAR_NODE_LISTEN=:8013 +# Set this when LINEAR_NODE_LISTEN includes a host, or when the control plane +# should call the node through a tunnel/container hostname. +LINEAR_NODE_PUBLIC_URL=http://localhost:8013 +LINEAR_API_KEY=lin_api_replace_me +LINEAR_WEBHOOK_SECRET=replace_me diff --git a/integrations/linear/node/Dockerfile b/integrations/linear/node/Dockerfile new file mode 100644 index 000000000..dd4c0684f --- /dev/null +++ b/integrations/linear/node/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY sdk/go /src/sdk/go +COPY integrations/linear/node /src/integrations/linear/node +WORKDIR /src/integrations/linear/node +RUN go build -o /out/linear-node ./cmd/linear-node + +FROM alpine:3.21 +RUN adduser -D -u 10001 agentfield +USER agentfield +COPY --from=build /out/linear-node /linear-node +EXPOSE 8013 +ENTRYPOINT ["/linear-node"] diff --git a/integrations/linear/node/README.md b/integrations/linear/node/README.md new file mode 100644 index 000000000..f34aa8acb --- /dev/null +++ b/integrations/linear/node/README.md @@ -0,0 +1,14 @@ +# Linear Node + +Run locally: + +```bash +export AGENTFIELD_SERVER=http://localhost:8080 +export LINEAR_API_KEY=lin_api_... +go run ./cmd/linear-node +``` + +Set `LINEAR_NODE_PUBLIC_URL` when the control plane should call the node +through a tunnel, container hostname, or host-qualified listen address. + +For local tests without a Linear account, set `LINEAR_API_URL` to a mock GraphQL server. The unit tests use `httptest` and do not call Linear. diff --git a/integrations/linear/node/cmd/linear-node/main.go b/integrations/linear/node/cmd/linear-node/main.go new file mode 100644 index 000000000..cacdac802 --- /dev/null +++ b/integrations/linear/node/cmd/linear-node/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/capabilities" + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/linear" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +func main() { + cfg := config.Load() + logger := log.New(os.Stdout, "[linear-node] ", log.LstdFlags) + + client, err := linear.NewClient(cfg.Linear) + if err != nil { + logger.Fatalf("initialize linear client: %v", err) + } + agentCfg := cfg.AgentConfig() + agentCfg.Logger = logger + agent, err := afagent.New(agentCfg) + if err != nil { + logger.Fatalf("initialize agent: %v", err) + } + capabilities.Register(agent, capabilities.Runtime{Config: cfg, Linear: client}) + if err := agent.Run(context.Background()); err != nil { + logger.Fatal(err) + } +} diff --git a/integrations/linear/node/go.mod b/integrations/linear/node/go.mod new file mode 100644 index 000000000..9d28ee00f --- /dev/null +++ b/integrations/linear/node/go.mod @@ -0,0 +1,9 @@ +module github.com/Agent-Field/agentfield/integrations/linear/node + +go 1.23 + +require github.com/Agent-Field/agentfield/sdk/go v0.0.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +replace github.com/Agent-Field/agentfield/sdk/go => ../../../sdk/go diff --git a/integrations/linear/node/go.sum b/integrations/linear/node/go.sum new file mode 100644 index 000000000..fa4b6e682 --- /dev/null +++ b/integrations/linear/node/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/linear/node/internal/capabilities/capabilities.go b/integrations/linear/node/internal/capabilities/capabilities.go new file mode 100644 index 000000000..218262434 --- /dev/null +++ b/integrations/linear/node/internal/capabilities/capabilities.go @@ -0,0 +1,86 @@ +package capabilities + +import ( + "context" + + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/linear" + "github.com/Agent-Field/agentfield/sdk/go/inputs" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +type Runtime struct { + Config config.Config + Linear *linear.Client +} + +func Register(agent *afagent.Agent, rt Runtime) { + agent.RegisterReasoner("health", rt.health, afagent.WithDescription("Report Linear node connectivity and configuration")) + agent.RegisterReasoner("get_issue", rt.getIssue, afagent.WithDescription("Read one Linear issue by UUID or identifier")) + agent.RegisterReasoner("list_issues", rt.listIssues, afagent.WithDescription("List recent Linear issues")) + agent.RegisterReasoner("create_issue", rt.createIssue, afagent.WithDescription("Create a Linear issue with IssueCreateInput fields")) + agent.RegisterReasoner("update_issue", rt.updateIssue, afagent.WithDescription("Update a Linear issue with IssueUpdateInput fields")) + agent.RegisterReasoner("comment_issue", rt.commentIssue, afagent.WithDescription("Add a comment to a Linear issue")) + agent.RegisterReasoner("list_teams", rt.listTeams, afagent.WithDescription("List Linear teams available to the token")) + agent.RegisterReasoner("list_projects", rt.listProjects, afagent.WithDescription("List Linear projects available to the token")) +} + +func (rt Runtime) health(ctx context.Context, _ map[string]any) (any, error) { + out, err := rt.Linear.Health(ctx) + if err != nil { + return nil, err + } + return map[string]any{"status": "ok", "node_id": rt.Config.NodeID, "linear": out["data"]}, nil +} + +func (rt Runtime) getIssue(ctx context.Context, input map[string]any) (any, error) { + id, err := inputs.RequiredString(input, "id") + if err != nil { + return nil, err + } + return rt.Linear.GetIssue(ctx, id) +} + +func (rt Runtime) listIssues(ctx context.Context, input map[string]any) (any, error) { + return rt.Linear.ListIssues(ctx, inputs.Int(input, "limit")) +} + +func (rt Runtime) createIssue(ctx context.Context, input map[string]any) (any, error) { + fields, err := inputs.Object(input, "input") + if err != nil { + return nil, err + } + return rt.Linear.CreateIssue(ctx, fields) +} + +func (rt Runtime) updateIssue(ctx context.Context, input map[string]any) (any, error) { + id, err := inputs.RequiredString(input, "id") + if err != nil { + return nil, err + } + fields, err := inputs.Object(input, "input") + if err != nil { + return nil, err + } + return rt.Linear.UpdateIssue(ctx, id, fields) +} + +func (rt Runtime) commentIssue(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + body, err := inputs.RequiredString(input, "body") + if err != nil { + return nil, err + } + return rt.Linear.CommentIssue(ctx, issueID, body) +} + +func (rt Runtime) listTeams(ctx context.Context, input map[string]any) (any, error) { + return rt.Linear.ListTeams(ctx, inputs.Int(input, "limit")) +} + +func (rt Runtime) listProjects(ctx context.Context, input map[string]any) (any, error) { + return rt.Linear.ListProjects(ctx, inputs.Int(input, "limit")) +} diff --git a/integrations/linear/node/internal/capabilities/capabilities_test.go b/integrations/linear/node/internal/capabilities/capabilities_test.go new file mode 100644 index 000000000..5ec405edc --- /dev/null +++ b/integrations/linear/node/internal/capabilities/capabilities_test.go @@ -0,0 +1,94 @@ +package capabilities + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/linear" +) + +func TestRuntimeCapabilitiesUseLinearGraphQL(t *testing.T) { + var calls []string + var gotAuth string + var gotCommentInput map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(req.Query, "AgentFieldLinearHealth"): + calls = append(calls, "health") + _, _ = w.Write([]byte(`{"data":{"viewer":{"id":"usr-1","name":"AgentField"}}}`)) + case strings.Contains(req.Query, "AgentFieldGetIssue"): + calls = append(calls, "get_issue") + if req.Variables["id"] != "AF-1" { + t.Fatalf("issue id variables=%v", req.Variables) + } + _, _ = w.Write([]byte(`{"data":{"issue":{"id":"issue-1","identifier":"AF-1"}}}`)) + case strings.Contains(req.Query, "AgentFieldCommentIssue"): + calls = append(calls, "comment_issue") + input, _ := req.Variables["input"].(map[string]any) + gotCommentInput = input + _, _ = w.Write([]byte(`{"data":{"commentCreate":{"success":true,"comment":{"id":"comment-1"}}}}`)) + default: + t.Fatalf("unexpected query: %s", req.Query) + } + })) + defer server.Close() + + client, err := linear.NewClient(linear.Config{ + APIURL: server.URL, + Token: "lin_mock_token", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + rt := Runtime{Config: config.Config{NodeID: "linear-prod"}, Linear: client} + + health, err := rt.health(context.Background(), nil) + if err != nil { + t.Fatalf("health: %v", err) + } + if health.(map[string]any)["status"] != "ok" { + t.Fatalf("health=%v", health) + } + if _, err := rt.getIssue(context.Background(), map[string]any{"id": "AF-1"}); err != nil { + t.Fatalf("getIssue: %v", err) + } + if _, err := rt.commentIssue(context.Background(), map[string]any{"issue_id": "issue-1", "body": "confirmed"}); err != nil { + t.Fatalf("commentIssue: %v", err) + } + if gotAuth != "lin_mock_token" { + t.Fatalf("auth=%q", gotAuth) + } + if gotCommentInput["issueId"] != "issue-1" || gotCommentInput["body"] != "confirmed" { + t.Fatalf("comment input=%v", gotCommentInput) + } + if strings.Join(calls, ",") != "health,get_issue,comment_issue" { + t.Fatalf("calls=%v", calls) + } +} + +func TestRuntimeRejectsMissingLinearCapabilityInput(t *testing.T) { + rt := Runtime{} + if _, err := rt.getIssue(context.Background(), map[string]any{}); err == nil { + t.Fatal("expected get_issue to require id") + } + if _, err := rt.commentIssue(context.Background(), map[string]any{"issue_id": "issue-1"}); err == nil { + t.Fatal("expected comment_issue to require body") + } +} diff --git a/integrations/linear/node/internal/config/config.go b/integrations/linear/node/internal/config/config.go new file mode 100644 index 000000000..51e545df7 --- /dev/null +++ b/integrations/linear/node/internal/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/integrations/linear/node/internal/linear" + "github.com/Agent-Field/agentfield/sdk/go/inputs" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +type Config struct { + NodeID string + Version string + AgentFieldURL string + ListenAddress string + PublicURL string + Token string + Linear linear.Config +} + +func Load() Config { + return Config{ + NodeID: env("AGENTFIELD_NODE_ID", "linear"), + Version: env("LINEAR_NODE_VERSION", "0.1.0"), + AgentFieldURL: env("AGENTFIELD_SERVER", env("AGENTFIELD_URL", "http://localhost:8080")), + ListenAddress: env("LINEAR_NODE_LISTEN", ":8013"), + PublicURL: os.Getenv("LINEAR_NODE_PUBLIC_URL"), + Token: os.Getenv("AGENTFIELD_TOKEN"), + Linear: linear.Config{ + APIURL: env("LINEAR_API_URL", "https://api.linear.app/graphql"), + Token: inputs.FirstNonBlank(os.Getenv("LINEAR_API_KEY"), os.Getenv("LINEAR_TOKEN")), + TimeoutSeconds: envInt("LINEAR_TIMEOUT_SECONDS", 20), + }, + } +} + +func (c Config) AgentConfig() afagent.Config { + return afagent.Config{ + NodeID: c.NodeID, + Version: c.Version, + AgentFieldURL: c.AgentFieldURL, + ListenAddress: c.ListenAddress, + PublicURL: c.PublicURL, + Token: c.Token, + Tags: []string{"linear", "issue-tracking", "project-management"}, + } +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/integrations/linear/node/internal/linear/client.go b/integrations/linear/node/internal/linear/client.go new file mode 100644 index 000000000..db0c9c91e --- /dev/null +++ b/integrations/linear/node/internal/linear/client.go @@ -0,0 +1,184 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Config struct { + APIURL string + Token string + TimeoutSeconds int + HTTPClient *http.Client +} + +type Client struct { + cfg Config + httpClient *http.Client +} + +func NewClient(cfg Config) (*Client, error) { + cfg.APIURL = strings.TrimSpace(cfg.APIURL) + if cfg.APIURL == "" { + cfg.APIURL = "https://api.linear.app/graphql" + } + if strings.TrimSpace(cfg.Token) == "" { + return nil, errors.New("linear API token is required") + } + if cfg.TimeoutSeconds <= 0 { + cfg.TimeoutSeconds = 20 + } + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second} + } + return &Client{cfg: cfg, httpClient: httpClient}, nil +} + +func (c *Client) Health(ctx context.Context) (map[string]any, error) { + return c.graphql(ctx, `query AgentFieldLinearHealth { viewer { id name email } }`, nil) +} + +func (c *Client) GetIssue(ctx context.Context, id string) (map[string]any, error) { + if strings.TrimSpace(id) == "" { + return nil, errors.New("id is required") + } + return c.graphql(ctx, `query AgentFieldGetIssue($id: String!) { + issue(id: $id) { + id identifier title description url priority estimate createdAt updatedAt + state { id name type } + team { id key name } + assignee { id name email } + project { id name } + cycle { id name number } + } + }`, map[string]any{"id": id}) +} + +func (c *Client) ListIssues(ctx context.Context, first int) (map[string]any, error) { + if first <= 0 || first > 100 { + first = 25 + } + return c.graphql(ctx, `query AgentFieldListIssues($first: Int!) { + issues(first: $first) { + nodes { + id identifier title url priority createdAt updatedAt + state { id name type } + team { id key name } + assignee { id name email } + } + } + }`, map[string]any{"first": first}) +} + +func (c *Client) CreateIssue(ctx context.Context, input map[string]any) (map[string]any, error) { + if len(input) == 0 { + return nil, errors.New("input is required") + } + return c.graphql(ctx, `mutation AgentFieldCreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier title url createdAt updatedAt state { id name type } } + } + }`, map[string]any{"input": input}) +} + +func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]any) (map[string]any, error) { + if strings.TrimSpace(id) == "" { + return nil, errors.New("id is required") + } + if len(input) == 0 { + return nil, errors.New("input is required") + } + return c.graphql(ctx, `mutation AgentFieldUpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { id identifier title url createdAt updatedAt state { id name type } } + } + }`, map[string]any{"id": id, "input": input}) +} + +func (c *Client) CommentIssue(ctx context.Context, issueID, body string) (map[string]any, error) { + if strings.TrimSpace(issueID) == "" { + return nil, errors.New("issue_id is required") + } + if strings.TrimSpace(body) == "" { + return nil, errors.New("body is required") + } + return c.graphql(ctx, `mutation AgentFieldCommentIssue($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id body url createdAt issue { id identifier } } + } + }`, map[string]any{"input": map[string]any{"issueId": issueID, "body": body}}) +} + +func (c *Client) ListTeams(ctx context.Context, first int) (map[string]any, error) { + if first <= 0 || first > 100 { + first = 50 + } + return c.graphql(ctx, `query AgentFieldListTeams($first: Int!) { + teams(first: $first) { + nodes { id key name description } + } + }`, map[string]any{"first": first}) +} + +func (c *Client) ListProjects(ctx context.Context, first int) (map[string]any, error) { + if first <= 0 || first > 100 { + first = 50 + } + return c.graphql(ctx, `query AgentFieldListProjects($first: Int!) { + projects(first: $first) { + nodes { id name description state url createdAt updatedAt } + } + }`, map[string]any{"first": first}) +} + +func (c *Client) graphql(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { + payload, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", authHeader(c.cfg.Token)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("linear GraphQL status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + if rawErrors, ok := out["errors"]; ok { + return nil, fmt.Errorf("linear GraphQL errors: %v", rawErrors) + } + return out, nil +} + +// authHeader returns the Authorization header value for the given token. +// OAuth tokens (starting with "lin_oauth_") are prefixed with "Bearer ", +// while personal API keys are returned raw. +func authHeader(token string) string { + if strings.HasPrefix(token, "lin_oauth_") { + return "Bearer " + token + } + return token +} diff --git a/integrations/linear/node/internal/linear/client_test.go b/integrations/linear/node/internal/linear/client_test.go new file mode 100644 index 000000000..cdd29501a --- /dev/null +++ b/integrations/linear/node/internal/linear/client_test.go @@ -0,0 +1,97 @@ +package linear + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGraphQLClientSendsAuthAndVariables(t *testing.T) { + var gotAuth string + var gotQuery string + var gotVariables map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + gotQuery = req.Query + gotVariables = req.Variables + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issue":{"id":"issue-1","identifier":"AF-1"}}}`)) + })) + defer server.Close() + + client, err := NewClient(Config{APIURL: server.URL, Token: "lin_api_key", HTTPClient: server.Client()}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + out, err := client.GetIssue(context.Background(), "AF-1") + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if gotAuth != "lin_api_key" { + t.Fatalf("auth=%q", gotAuth) + } + if !strings.Contains(gotQuery, "issue(id: $id)") { + t.Fatalf("unexpected query: %s", gotQuery) + } + if gotVariables["id"] != "AF-1" || out["data"] == nil { + t.Fatalf("bad variables/out: %v %v", gotVariables, out) + } +} + +func TestCreateIssueRequiresInput(t *testing.T) { + client, err := NewClient(Config{APIURL: "http://linear.test/graphql", Token: "lin"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := client.CreateIssue(context.Background(), nil); err == nil { + t.Fatal("expected input error") + } +} + +func TestGraphQLErrorsFailCall(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"bad request"}]}`)) + })) + defer server.Close() + + client, err := NewClient(Config{APIURL: server.URL, Token: "lin", HTTPClient: server.Client()}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := client.ListIssues(context.Background(), 10); err == nil || !strings.Contains(err.Error(), "GraphQL errors") { + t.Fatalf("expected GraphQL error, got %v", err) + } +} + +func TestGraphQLClientUsesBearerForOAuthTokens(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"viewer":{"id":"user-1","name":"Test User"}}}`)) + })) + defer server.Close() + + client, err := NewClient(Config{APIURL: server.URL, Token: "lin_oauth_example_token_xyz", HTTPClient: server.Client()}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = client.Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if gotAuth != "Bearer lin_oauth_example_token_xyz" { + t.Fatalf("auth=%q, want Bearer lin_oauth_example_token_xyz", gotAuth) + } +} diff --git a/integrations/sentry/README.md b/integrations/sentry/README.md new file mode 100644 index 000000000..cbd6b37a7 --- /dev/null +++ b/integrations/sentry/README.md @@ -0,0 +1,46 @@ +# Sentry Integration + +This pack ships a first-party Sentry webhook source plus a deterministic Go capability node. + +Use the source when Sentry should start an AgentField run from issue, alert, error, or comment webhooks. Use the node when an agent needs bounded Sentry API operations such as reading an issue, listing events, resolving an issue, or assigning ownership. + +## Trigger Source + +- Source name: `sentry` +- Kind: HTTP webhook +- Secret: `SENTRY_CLIENT_SECRET` +- Signature: `Sentry-Hook-Signature` HMAC-SHA256 +- Idempotency: `Request-ID`, with a body hash fallback +- Event type: `.`, lowercased, for example `issue.created` +- Timestamp validation: `Sentry-Hook-Timestamp` (300s tolerance by default, configurable) + +## Capability Node + +Required env: + +```bash +export SENTRY_AUTH_TOKEN=sntrys_... +export SENTRY_ORG=agentfield +``` + +Useful capabilities: + +- `list_issues`: list issues for a project and optional Sentry search query. +- `get_issue`: read one issue by issue ID. +- `list_issue_events` and `get_event`: inspect captured event payloads. +- `resolve_issue`, `assign_issue`, and `update_issue`: explicit issue mutations. + +These are provider API calls. They only wrap explicit Sentry issue and event operations. + +## Region + +Sentry's API base URL depends on your organization's data-storage region. Set `SENTRY_BASE_URL` accordingly: + +| Region | Value | +|--------|-------| +| Legacy US-only | `https://sentry.io` | +| US region | `https://us.sentry.io` | +| **EU region** | `https://de.sentry.io` | +| Self-hosted | Your install's hostname | + +**EU customers must set `SENTRY_BASE_URL` explicitly.** The default `https://sentry.io` will fail for EU-region orgs. diff --git a/integrations/sentry/agentfield-package.yaml b/integrations/sentry/agentfield-package.yaml new file mode 100644 index 000000000..6c6ac71d1 --- /dev/null +++ b/integrations/sentry/agentfield-package.yaml @@ -0,0 +1,67 @@ +name: sentry +version: 0.1.0 +description: Sentry trigger source and deterministic capability node for issue and event workflows. +author: AgentField +type: agent_node +main: node/sentry-node + +agent_node: + node_id: sentry-prod + default_port: 8014 + +dependencies: + python: [] + system: [] + +capabilities: + skills: + - name: health + description: Confirm Sentry API connectivity for the configured token. + - name: list_issues + description: List issues for a Sentry project. + - name: get_issue + description: Read one Sentry issue by issue ID. + - name: list_issue_events + description: List events captured for an issue. + - name: get_event + description: Read one captured issue event. + - name: update_issue + description: Update issue fields through the Sentry API. + - name: resolve_issue + description: Mark an issue resolved. + - name: assign_issue + description: Assign an issue to a Sentry user or team identifier. + +runtime: + auto_port: true + heartbeat_interval: 30 + dev_mode: false + +environment: + AGENTFIELD_NODE_ID: sentry-prod + AGENTFIELD_SERVER: http://localhost:8080 + +user_environment: + required: + - name: SENTRY_AUTH_TOKEN + description: Sentry API auth token. + type: secret + - name: SENTRY_ORG + description: Sentry organization slug. + type: string + optional: + - name: SENTRY_BASE_URL + description: Sentry API base URL. Use https://de.sentry.io for EU orgs (mandatory), https://us.sentry.io for US-region, https://sentry.io for legacy US-only. + type: string + default: https://sentry.io + - name: SENTRY_CLIENT_SECRET + description: Integration client secret used by the control-plane sentry source. + type: secret + +metadata: + created_at: "2026-06-15" + sdk_version: ">=0.1.92" + language: go + platform: sentry + integration_kind: trigger_source_and_capability_node + control_plane_source: built_in diff --git a/integrations/sentry/contracts/capabilities.yaml b/integrations/sentry/contracts/capabilities.yaml new file mode 100644 index 000000000..bf7d11822 --- /dev/null +++ b/integrations/sentry/contracts/capabilities.yaml @@ -0,0 +1,68 @@ +version: 2026-06-15-v1 +node: + default_id: sentry-prod + tags: + - sentry + - observability + - errors + +principles: + - Capabilities are deterministic Sentry API operations, not investigation workflows. + - Reads preserve issue IDs, event IDs, tags, project IDs, and timestamps from Sentry responses. + - Writes are explicit issue mutations only. + +skills: + health: + input: {} + output: + status: string + projects: list + + list_issues: + input: + project: string + query: optional string + limit: optional integer + output: + issues: list + + get_issue: + input: + issue_id: string + output: + issue: object + + list_issue_events: + input: + issue_id: string + query: optional string + limit: optional integer + output: + events: list + + get_event: + input: + issue_id: string + event_id: string + output: + event: object + + update_issue: + input: + issue_id: string + input: object + output: + issue: object + + resolve_issue: + input: + issue_id: string + output: + issue: object + + assign_issue: + input: + issue_id: string + assignee: string + output: + issue: object diff --git a/integrations/sentry/contracts/trigger-source.yaml b/integrations/sentry/contracts/trigger-source.yaml new file mode 100644 index 000000000..1dfe4192f --- /dev/null +++ b/integrations/sentry/contracts/trigger-source.yaml @@ -0,0 +1,40 @@ +version: 2026-06-15-v1 +source: + name: sentry + kind: http + secret_required: true + +signature: + header: Sentry-Hook-Signature + algorithm: HMAC-SHA256 over JSON webhook payload + secret: Sentry integration Client Secret + +headers: + request_id: Request-ID + resource: Sentry-Hook-Resource + timestamp: Sentry-Hook-Timestamp + +normalized_event: + event_type: lower(Sentry-Hook-Resource) + "." + lower(action) + idempotency_key: Request-ID, falling back to SHA-256(resource + body) + fields: + action: string + resource: string + installation: object + data: object + actor: object + sentry: + request_id: string + timestamp: string + resource: string + +event_contract: + examples: + - issue.created + - issue.resolved + - event_alert.triggered + - metric_alert.triggered + - comment.created + - error.created + replay: + behavior: Replays reuse the persisted normalized event and do not call Sentry. diff --git a/integrations/sentry/node/.env.example b/integrations/sentry/node/.env.example new file mode 100644 index 000000000..a49f4e40e --- /dev/null +++ b/integrations/sentry/node/.env.example @@ -0,0 +1,9 @@ +AGENTFIELD_NODE_ID=sentry-dev +AGENTFIELD_SERVER=http://localhost:8080 +SENTRY_NODE_LISTEN=:8014 +# Set this when SENTRY_NODE_LISTEN includes a host, or when the control plane +# should call the node through a tunnel/container hostname. +SENTRY_NODE_PUBLIC_URL=http://localhost:8014 +SENTRY_AUTH_TOKEN=sntrys_replace_me +SENTRY_ORG=replace_me +SENTRY_CLIENT_SECRET=replace_me diff --git a/integrations/sentry/node/Dockerfile b/integrations/sentry/node/Dockerfile new file mode 100644 index 000000000..dc0d656ed --- /dev/null +++ b/integrations/sentry/node/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY sdk/go /src/sdk/go +COPY integrations/sentry/node /src/integrations/sentry/node +WORKDIR /src/integrations/sentry/node +RUN go build -o /out/sentry-node ./cmd/sentry-node + +FROM alpine:3.21 +RUN adduser -D -u 10001 agentfield +USER agentfield +COPY --from=build /out/sentry-node /sentry-node +EXPOSE 8014 +ENTRYPOINT ["/sentry-node"] diff --git a/integrations/sentry/node/README.md b/integrations/sentry/node/README.md new file mode 100644 index 000000000..8e1e9c121 --- /dev/null +++ b/integrations/sentry/node/README.md @@ -0,0 +1,15 @@ +# Sentry Node + +Run locally: + +```bash +export AGENTFIELD_SERVER=http://localhost:8080 +export SENTRY_AUTH_TOKEN=sntrys_... +export SENTRY_ORG=agentfield +go run ./cmd/sentry-node +``` + +Set `SENTRY_NODE_PUBLIC_URL` when the control plane should call the node +through a tunnel, container hostname, or host-qualified listen address. + +For local tests without a Sentry account, set `SENTRY_BASE_URL` to a mock Sentry API server. The unit tests use `httptest` and do not call Sentry. diff --git a/integrations/sentry/node/cmd/sentry-node/main.go b/integrations/sentry/node/cmd/sentry-node/main.go new file mode 100644 index 000000000..9ce9a78f2 --- /dev/null +++ b/integrations/sentry/node/cmd/sentry-node/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/capabilities" + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/sentry" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +func main() { + cfg := config.Load() + logger := log.New(os.Stdout, "[sentry-node] ", log.LstdFlags) + + client, err := sentry.NewClient(cfg.Sentry) + if err != nil { + logger.Fatalf("initialize sentry client: %v", err) + } + agentCfg := cfg.AgentConfig() + agentCfg.Logger = logger + agent, err := afagent.New(agentCfg) + if err != nil { + logger.Fatalf("initialize agent: %v", err) + } + capabilities.Register(agent, capabilities.Runtime{Config: cfg, Sentry: client}) + if err := agent.Run(context.Background()); err != nil { + logger.Fatal(err) + } +} diff --git a/integrations/sentry/node/go.mod b/integrations/sentry/node/go.mod new file mode 100644 index 000000000..2fb89f0c9 --- /dev/null +++ b/integrations/sentry/node/go.mod @@ -0,0 +1,9 @@ +module github.com/Agent-Field/agentfield/integrations/sentry/node + +go 1.23 + +require github.com/Agent-Field/agentfield/sdk/go v0.0.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +replace github.com/Agent-Field/agentfield/sdk/go => ../../../sdk/go diff --git a/integrations/sentry/node/go.sum b/integrations/sentry/node/go.sum new file mode 100644 index 000000000..fa4b6e682 --- /dev/null +++ b/integrations/sentry/node/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/sentry/node/internal/capabilities/capabilities.go b/integrations/sentry/node/internal/capabilities/capabilities.go new file mode 100644 index 000000000..cb842dde2 --- /dev/null +++ b/integrations/sentry/node/internal/capabilities/capabilities.go @@ -0,0 +1,102 @@ +package capabilities + +import ( + "context" + + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/sentry" + "github.com/Agent-Field/agentfield/sdk/go/inputs" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +type Runtime struct { + Config config.Config + Sentry *sentry.Client +} + +func Register(agent *afagent.Agent, rt Runtime) { + agent.RegisterReasoner("health", rt.health, afagent.WithDescription("Report Sentry node connectivity and configuration")) + agent.RegisterReasoner("list_issues", rt.listIssues, afagent.WithDescription("List Sentry issues for a project")) + agent.RegisterReasoner("get_issue", rt.getIssue, afagent.WithDescription("Read one Sentry issue by issue ID")) + agent.RegisterReasoner("list_issue_events", rt.listIssueEvents, afagent.WithDescription("List events captured for a Sentry issue")) + agent.RegisterReasoner("get_event", rt.getEvent, afagent.WithDescription("Read one event captured for a Sentry issue")) + agent.RegisterReasoner("update_issue", rt.updateIssue, afagent.WithDescription("Update Sentry issue fields")) + agent.RegisterReasoner("resolve_issue", rt.resolveIssue, afagent.WithDescription("Mark a Sentry issue resolved")) + agent.RegisterReasoner("assign_issue", rt.assignIssue, afagent.WithDescription("Assign a Sentry issue to a user or team identifier")) +} + +func (rt Runtime) health(ctx context.Context, _ map[string]any) (any, error) { + out, err := rt.Sentry.Health(ctx) + if err != nil { + return nil, err + } + return map[string]any{"status": "ok", "node_id": rt.Config.NodeID, "sentry": out}, nil +} + +func (rt Runtime) listIssues(ctx context.Context, input map[string]any) (any, error) { + project, err := inputs.RequiredString(input, "project") + if err != nil { + return nil, err + } + return rt.Sentry.ListIssues(ctx, project, inputs.String(input, "query"), inputs.Int(input, "limit")) +} + +func (rt Runtime) getIssue(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + return rt.Sentry.GetIssue(ctx, issueID) +} + +func (rt Runtime) listIssueEvents(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + return rt.Sentry.ListIssueEvents(ctx, issueID, inputs.String(input, "query"), inputs.Int(input, "limit")) +} + +func (rt Runtime) getEvent(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + eventID, err := inputs.RequiredString(input, "event_id") + if err != nil { + return nil, err + } + return rt.Sentry.GetEvent(ctx, issueID, eventID) +} + +func (rt Runtime) updateIssue(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + fields, err := inputs.Object(input, "input") + if err != nil { + return nil, err + } + return rt.Sentry.UpdateIssue(ctx, issueID, fields) +} + +func (rt Runtime) resolveIssue(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + return rt.Sentry.ResolveIssue(ctx, issueID) +} + +func (rt Runtime) assignIssue(ctx context.Context, input map[string]any) (any, error) { + issueID, err := inputs.RequiredString(input, "issue_id") + if err != nil { + return nil, err + } + assignee, err := inputs.RequiredString(input, "assignee") + if err != nil { + return nil, err + } + return rt.Sentry.AssignIssue(ctx, issueID, assignee) +} diff --git a/integrations/sentry/node/internal/capabilities/capabilities_test.go b/integrations/sentry/node/internal/capabilities/capabilities_test.go new file mode 100644 index 000000000..c26c17dbe --- /dev/null +++ b/integrations/sentry/node/internal/capabilities/capabilities_test.go @@ -0,0 +1,89 @@ +package capabilities + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/config" + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/sentry" +) + +func TestRuntimeCapabilitiesUseSentryAPI(t *testing.T) { + var calls []string + var gotAuth string + var gotAssignBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/0/organizations/agentfield/projects/": + calls = append(calls, "health") + _, _ = w.Write([]byte(`[{"slug":"web"}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/0/projects/agentfield/web/issues/": + calls = append(calls, "list_issues") + if r.URL.Query().Get("query") != "is:unresolved" { + t.Fatalf("query=%q", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`[{"id":"issue-1"}]`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/0/organizations/agentfield/issues/issue-1/": + calls = append(calls, "assign_issue") + if err := json.NewDecoder(r.Body).Decode(&gotAssignBody); err != nil { + t.Fatalf("decode assign body: %v", err) + } + _, _ = w.Write([]byte(`{"id":"issue-1","assignedTo":"user:alice@example.com"}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client, err := sentry.NewClient(sentry.Config{ + BaseURL: server.URL, + Organization: "agentfield", + Token: "sntrys_mock_token", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + rt := Runtime{Config: config.Config{NodeID: "sentry-prod"}, Sentry: client} + + health, err := rt.health(context.Background(), nil) + if err != nil { + t.Fatalf("health: %v", err) + } + if health.(map[string]any)["status"] != "ok" { + t.Fatalf("health=%v", health) + } + if _, err := rt.listIssues(context.Background(), map[string]any{"project": "web", "query": "is:unresolved", "limit": 10}); err != nil { + t.Fatalf("listIssues: %v", err) + } + if _, err := rt.assignIssue(context.Background(), map[string]any{"issue_id": "issue-1", "assignee": "user:alice@example.com"}); err != nil { + t.Fatalf("assignIssue: %v", err) + } + if gotAuth != "Bearer sntrys_mock_token" { + t.Fatalf("auth=%q", gotAuth) + } + if gotAssignBody["assignedTo"] != "user:alice@example.com" { + t.Fatalf("assign body=%v", gotAssignBody) + } + if strings.Join(calls, ",") != "health,list_issues,assign_issue" { + t.Fatalf("calls=%v", calls) + } +} + +func TestRuntimeRejectsMissingSentryCapabilityInput(t *testing.T) { + rt := Runtime{} + if _, err := rt.listIssues(context.Background(), map[string]any{}); err == nil { + t.Fatal("expected list_issues to require project") + } + if _, err := rt.assignIssue(context.Background(), map[string]any{"issue_id": "issue-1"}); err == nil { + t.Fatal("expected assign_issue to require assignee") + } +} diff --git a/integrations/sentry/node/internal/config/config.go b/integrations/sentry/node/internal/config/config.go new file mode 100644 index 000000000..81556c56f --- /dev/null +++ b/integrations/sentry/node/internal/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "os" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/integrations/sentry/node/internal/sentry" + "github.com/Agent-Field/agentfield/sdk/go/inputs" + afagent "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +type Config struct { + NodeID string + Version string + AgentFieldURL string + ListenAddress string + PublicURL string + Token string + Sentry sentry.Config +} + +func Load() Config { + return Config{ + NodeID: env("AGENTFIELD_NODE_ID", "sentry"), + Version: env("SENTRY_NODE_VERSION", "0.1.0"), + AgentFieldURL: env("AGENTFIELD_SERVER", env("AGENTFIELD_URL", "http://localhost:8080")), + ListenAddress: env("SENTRY_NODE_LISTEN", ":8014"), + PublicURL: os.Getenv("SENTRY_NODE_PUBLIC_URL"), + Token: os.Getenv("AGENTFIELD_TOKEN"), + Sentry: sentry.Config{ + BaseURL: env("SENTRY_BASE_URL", "https://sentry.io"), + Organization: os.Getenv("SENTRY_ORG"), + Token: inputs.FirstNonBlank(os.Getenv("SENTRY_AUTH_TOKEN"), os.Getenv("SENTRY_TOKEN")), + TimeoutSeconds: envInt("SENTRY_TIMEOUT_SECONDS", 20), + }, + } +} + +func (c Config) AgentConfig() afagent.Config { + return afagent.Config{ + NodeID: c.NodeID, + Version: c.Version, + AgentFieldURL: c.AgentFieldURL, + ListenAddress: c.ListenAddress, + PublicURL: c.PublicURL, + Token: c.Token, + Tags: []string{"sentry", "observability", "errors"}, + } +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/integrations/sentry/node/internal/sentry/client.go b/integrations/sentry/node/internal/sentry/client.go new file mode 100644 index 000000000..33d368378 --- /dev/null +++ b/integrations/sentry/node/internal/sentry/client.go @@ -0,0 +1,182 @@ +package sentry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Config struct { + BaseURL string + Organization string + Token string + TimeoutSeconds int + HTTPClient *http.Client +} + +type Client struct { + cfg Config + httpClient *http.Client +} + +func NewClient(cfg Config) (*Client, error) { + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if cfg.BaseURL == "" { + cfg.BaseURL = "https://sentry.io" + } + cfg.Organization = strings.TrimSpace(cfg.Organization) + if strings.TrimSpace(cfg.Token) == "" { + return nil, errors.New("sentry auth token is required") + } + if cfg.TimeoutSeconds <= 0 { + cfg.TimeoutSeconds = 20 + } + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second} + } + return &Client{cfg: cfg, httpClient: httpClient}, nil +} + +func (c *Client) Health(ctx context.Context) (any, error) { + if c.cfg.Organization == "" { + return map[string]any{"status": "configured", "organization_required_for": []string{"list_issues", "list_issue_events", "get_event"}}, nil + } + return c.get(ctx, fmt.Sprintf("/api/0/organizations/%s/projects/", url.PathEscape(c.cfg.Organization)), nil) +} + +// TODO: surface the Link header for cursor-based pagination — current callers only get the first page. +func (c *Client) ListIssues(ctx context.Context, project string, query string, limit int) (any, error) { + if c.cfg.Organization == "" { + return nil, errors.New("organization is required") + } + if strings.TrimSpace(project) == "" { + return nil, errors.New("project is required") + } + params := url.Values{} + if strings.TrimSpace(query) != "" { + params.Set("query", query) + } + if limit > 0 { + params.Set("per_page", fmt.Sprintf("%d", limit)) + } + // TODO: migrate to /api/0/organizations/{org}/issues/?project= once we resolve slug→id + return c.get(ctx, fmt.Sprintf("/api/0/projects/%s/%s/issues/", url.PathEscape(c.cfg.Organization), url.PathEscape(project)), params) +} + +func (c *Client) GetIssue(ctx context.Context, issueID string) (any, error) { + if c.cfg.Organization == "" { + return nil, errors.New("organization is required") + } + if strings.TrimSpace(issueID) == "" { + return nil, errors.New("issue_id is required") + } + return c.get(ctx, fmt.Sprintf("/api/0/organizations/%s/issues/%s/", url.PathEscape(c.cfg.Organization), url.PathEscape(issueID)), nil) +} + +// TODO: surface the Link header for cursor-based pagination — current callers only get the first page. +func (c *Client) ListIssueEvents(ctx context.Context, issueID string, query string, limit int) (any, error) { + if c.cfg.Organization == "" { + return nil, errors.New("organization is required") + } + if strings.TrimSpace(issueID) == "" { + return nil, errors.New("issue_id is required") + } + params := url.Values{} + if strings.TrimSpace(query) != "" { + params.Set("query", query) + } + if limit > 0 { + params.Set("per_page", fmt.Sprintf("%d", limit)) + } + return c.get(ctx, fmt.Sprintf("/api/0/organizations/%s/issues/%s/events/", url.PathEscape(c.cfg.Organization), url.PathEscape(issueID)), params) +} + +func (c *Client) GetEvent(ctx context.Context, issueID, eventID string) (any, error) { + if c.cfg.Organization == "" { + return nil, errors.New("organization is required") + } + if strings.TrimSpace(issueID) == "" { + return nil, errors.New("issue_id is required") + } + if strings.TrimSpace(eventID) == "" { + return nil, errors.New("event_id is required") + } + return c.get(ctx, fmt.Sprintf("/api/0/organizations/%s/issues/%s/events/%s/", url.PathEscape(c.cfg.Organization), url.PathEscape(issueID), url.PathEscape(eventID)), nil) +} + +func (c *Client) UpdateIssue(ctx context.Context, issueID string, fields map[string]any) (any, error) { + if c.cfg.Organization == "" { + return nil, errors.New("organization is required") + } + if strings.TrimSpace(issueID) == "" { + return nil, errors.New("issue_id is required") + } + if len(fields) == 0 { + return nil, errors.New("input is required") + } + return c.requestJSON(ctx, http.MethodPut, fmt.Sprintf("/api/0/organizations/%s/issues/%s/", url.PathEscape(c.cfg.Organization), url.PathEscape(issueID)), nil, fields) +} + +func (c *Client) ResolveIssue(ctx context.Context, issueID string) (any, error) { + return c.UpdateIssue(ctx, issueID, map[string]any{"status": "resolved"}) +} + +func (c *Client) AssignIssue(ctx context.Context, issueID, assignee string) (any, error) { + if strings.TrimSpace(assignee) == "" { + return nil, errors.New("assignee is required") + } + return c.UpdateIssue(ctx, issueID, map[string]any{"assignedTo": assignee}) +} + +func (c *Client) get(ctx context.Context, path string, params url.Values) (any, error) { + return c.requestJSON(ctx, http.MethodGet, path, params, nil) +} + +func (c *Client) requestJSON(ctx context.Context, method, path string, params url.Values, body any) (any, error) { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(payload) + } + u := c.cfg.BaseURL + path + if len(params) > 0 { + u += "?" + params.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("sentry API status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + if len(strings.TrimSpace(string(data))) == 0 { + return map[string]any{"ok": true}, nil + } + var out any + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/integrations/sentry/node/internal/sentry/client_test.go b/integrations/sentry/node/internal/sentry/client_test.go new file mode 100644 index 000000000..b04327ccb --- /dev/null +++ b/integrations/sentry/node/internal/sentry/client_test.go @@ -0,0 +1,89 @@ +package sentry + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClientSendsBearerAndBuildsIssueEventsPath(t *testing.T) { + var gotAuth string + var gotPath string + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"evt-1"}]`)) + })) + defer server.Close() + + client, err := NewClient(Config{BaseURL: server.URL, Organization: "agentfield", Token: "sntrys_token", HTTPClient: server.Client()}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + out, err := client.ListIssueEvents(context.Background(), "123", "level:error", 10) + if err != nil { + t.Fatalf("ListIssueEvents: %v", err) + } + if gotAuth != "Bearer sntrys_token" { + t.Fatalf("auth=%q", gotAuth) + } + if gotPath != "/api/0/organizations/agentfield/issues/123/events/" { + t.Fatalf("path=%q", gotPath) + } + if !strings.Contains(gotQuery, "query=level%3Aerror") { + t.Fatalf("query=%q", gotQuery) + } + if out == nil { + t.Fatal("expected parsed response") + } +} + +func TestUpdateIssueSendsBody(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"123","status":"resolved"}`)) + })) + defer server.Close() + + client, err := NewClient(Config{BaseURL: server.URL, Organization: "agentfield", Token: "tok", HTTPClient: server.Client()}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := client.ResolveIssue(context.Background(), "123"); err != nil { + t.Fatalf("ResolveIssue: %v", err) + } + if gotMethod != http.MethodPut || gotPath != "/api/0/organizations/agentfield/issues/123/" { + t.Fatalf("method/path=%s %s", gotMethod, gotPath) + } + if gotBody["status"] != "resolved" { + t.Fatalf("body=%v", gotBody) + } +} + +func TestListIssuesRequiresOrganizationAndProject(t *testing.T) { + client, err := NewClient(Config{BaseURL: "https://sentry.test", Token: "tok"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := client.ListIssues(context.Background(), "web", "", 10); err == nil || !strings.Contains(err.Error(), "organization") { + t.Fatalf("expected organization error, got %v", err) + } + client.cfg.Organization = "org" + if _, err := client.ListIssues(context.Background(), "", "", 10); err == nil || !strings.Contains(err.Error(), "project") { + t.Fatalf("expected project error, got %v", err) + } +} diff --git a/sdk/go/inputs/inputs.go b/sdk/go/inputs/inputs.go new file mode 100644 index 000000000..c8faf0432 --- /dev/null +++ b/sdk/go/inputs/inputs.go @@ -0,0 +1,78 @@ +// Package inputs provides typed accessors for the untyped map[string]any +// payloads that capability handlers receive from RegisterReasoner. Use these +// helpers instead of inline type assertions so all first-party nodes parse +// input shapes consistently. +package inputs + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// RequiredString returns the trimmed string value for key, or an error if the +// key is missing or empty. +func RequiredString(input map[string]any, key string) (string, error) { + value := String(input, key) + if value == "" { + return "", fmt.Errorf("%s is required", key) + } + return value, nil +} + +// String returns the trimmed string value for key, or "" if absent or not a +// string. +func String(input map[string]any, key string) string { + if input == nil { + return "" + } + value, ok := input[key].(string) + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +// Int returns the int value for key. It accepts native int, float64 +// (JSON-decoded numbers), and json.Number. Returns 0 when missing or +// unconvertible. +func Int(input map[string]any, key string) int { + if input == nil { + return 0 + } + switch value := input[key].(type) { + case int: + return value + case float64: + return int(value) + case json.Number: + n, _ := value.Int64() + return int(n) + default: + return 0 + } +} + +// Object returns the map value for key. Returns an error when missing or +// empty so callers can fail fast on malformed reasoner inputs. +func Object(input map[string]any, key string) (map[string]any, error) { + if input == nil { + return nil, errors.New(key + " is required") + } + value, ok := input[key].(map[string]any) + if !ok || len(value) == 0 { + return nil, errors.New(key + " is required") + } + return value, nil +} + +// FirstNonBlank returns the first trimmed-non-empty value in values, or "". +func FirstNonBlank(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/sdk/go/inputs/inputs_test.go b/sdk/go/inputs/inputs_test.go new file mode 100644 index 000000000..2848b2962 --- /dev/null +++ b/sdk/go/inputs/inputs_test.go @@ -0,0 +1,299 @@ +package inputs + +import ( + "encoding/json" + "testing" +) + +func TestRequiredString(t *testing.T) { + tests := []struct { + name string + input map[string]any + key string + want string + wantErr bool + }{ + { + name: "present and non-empty", + input: map[string]any{"name": "test"}, + key: "name", + want: "test", + wantErr: false, + }, + { + name: "present with whitespace", + input: map[string]any{"name": " test "}, + key: "name", + want: "test", + wantErr: false, + }, + { + name: "missing key", + input: map[string]any{}, + key: "name", + want: "", + wantErr: true, + }, + { + name: "empty string", + input: map[string]any{"name": ""}, + key: "name", + want: "", + wantErr: true, + }, + { + name: "only whitespace", + input: map[string]any{"name": " "}, + key: "name", + want: "", + wantErr: true, + }, + { + name: "nil input", + input: nil, + key: "name", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RequiredString(tt.input, tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("RequiredString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("RequiredString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + input map[string]any + key string + want string + }{ + { + name: "present string", + input: map[string]any{"name": "test"}, + key: "name", + want: "test", + }, + { + name: "present with whitespace", + input: map[string]any{"name": " test "}, + key: "name", + want: "test", + }, + { + name: "missing key", + input: map[string]any{}, + key: "name", + want: "", + }, + { + name: "nil input", + input: nil, + key: "name", + want: "", + }, + { + name: "non-string value", + input: map[string]any{"name": 42}, + key: "name", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := String(tt.input, tt.key) + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestInt(t *testing.T) { + tests := []struct { + name string + input map[string]any + key string + want int + }{ + { + name: "native int", + input: map[string]any{"count": 42}, + key: "count", + want: 42, + }, + { + name: "float64 from JSON", + input: map[string]any{"count": float64(42.0)}, + key: "count", + want: 42, + }, + { + name: "float64 truncated", + input: map[string]any{"count": float64(42.7)}, + key: "count", + want: 42, + }, + { + name: "json.Number", + input: func() map[string]any { + d := json.NewDecoder(nil) + d.UseNumber() + return map[string]any{"count": json.Number("42")} + }(), + key: "count", + want: 42, + }, + { + name: "missing key", + input: map[string]any{}, + key: "count", + want: 0, + }, + { + name: "nil input", + input: nil, + key: "count", + want: 0, + }, + { + name: "non-numeric value", + input: map[string]any{"count": "abc"}, + key: "count", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Int(tt.input, tt.key) + if got != tt.want { + t.Errorf("Int() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestObject(t *testing.T) { + tests := []struct { + name string + input map[string]any + key string + want map[string]any + wantErr bool + }{ + { + name: "present object", + input: map[string]any{"config": map[string]any{"key": "value"}}, + key: "config", + want: map[string]any{"key": "value"}, + wantErr: false, + }, + { + name: "missing key", + input: map[string]any{}, + key: "config", + want: nil, + wantErr: true, + }, + { + name: "empty object", + input: map[string]any{"config": map[string]any{}}, + key: "config", + want: nil, + wantErr: true, + }, + { + name: "nil input", + input: nil, + key: "config", + want: nil, + wantErr: true, + }, + { + name: "non-object value", + input: map[string]any{"config": "not an object"}, + key: "config", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Object(tt.input, tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("Object() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if got != nil { + t.Errorf("Object() = %v, want nil", got) + } + } else { + if got["key"] != tt.want["key"] { + t.Errorf("Object() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestFirstNonBlank(t *testing.T) { + tests := []struct { + name string + values []string + want string + }{ + { + name: "first is non-blank", + values: []string{"first", "second"}, + want: "first", + }, + { + name: "second is non-blank", + values: []string{"", "second", "third"}, + want: "second", + }, + { + name: "first with whitespace", + values: []string{" first ", "second"}, + want: "first", + }, + { + name: "all empty", + values: []string{"", "", ""}, + want: "", + }, + { + name: "no values", + values: []string{}, + want: "", + }, + { + name: "single non-blank", + values: []string{"only"}, + want: "only", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FirstNonBlank(tt.values...) + if got != tt.want { + t.Errorf("FirstNonBlank() = %q, want %q", got, tt.want) + } + }) + } +}