diff --git a/Makefile b/Makefile index 19a60ac1..f43c263d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image configuration REGISTRY ?= ghcr.io/kelos-dev VERSION ?= latest -IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher claude-code codex gemini opencode cursor +IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-webhook-server cmd/kelos-token-refresher claude-code codex gemini opencode cursor # Version injection for the kelos CLI – only set ldflags when an explicit # version is given so that dev builds fall through to runtime/debug info. diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index 7dc07719..a9d438bb 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -36,6 +36,18 @@ type When struct { // Jira discovers issues from a Jira project. // +optional Jira *Jira `json:"jira,omitempty"` + + // GitHubWebhook configures webhook-driven task spawning from GitHub events. + // A global webhook server watches TaskSpawners with this field set and + // creates Tasks when incoming webhooks match the configured filters. + // +optional + GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + + // LinearWebhook configures webhook-driven task spawning from Linear events. + // A global webhook server watches TaskSpawners with this field set and + // creates Tasks when incoming webhooks match the configured filters. + // +optional + LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` } // Cron triggers task spawning on a cron schedule. @@ -295,6 +307,93 @@ type Jira struct { PollInterval string `json:"pollInterval,omitempty"` } +// GitHubWebhook configures webhook-driven task spawning from GitHub events. +type GitHubWebhook struct { + // Events is the list of GitHub event types to listen for. + // e.g., "issue_comment", "pull_request_review", "push", "issues", "pull_request" + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Events []string `json:"events"` + + // Filters refine which events trigger tasks. If multiple filters are + // defined, any match triggers a task (OR semantics). If empty, all + // events in the Events list trigger tasks. + // +optional + Filters []GitHubWebhookFilter `json:"filters,omitempty"` +} + +// GitHubWebhookFilter defines criteria for matching a GitHub webhook event. +type GitHubWebhookFilter struct { + // Event is the GitHub event type this filter applies to. + // +kubebuilder:validation:Required + Event string `json:"event"` + + // Action filters by webhook action (e.g., "created", "opened", "submitted"). + // +optional + Action string `json:"action,omitempty"` + + // BodyContains filters by substring match on the comment or review body. + // +optional + BodyContains string `json:"bodyContains,omitempty"` + + // Labels requires the issue or PR to have all of these labels. + // +optional + Labels []string `json:"labels,omitempty"` + + // State filters by issue or PR state ("open", "closed"). + // +optional + State string `json:"state,omitempty"` + + // Branch filters push events by branch name (supports glob patterns). + // +optional + Branch string `json:"branch,omitempty"` + + // Draft filters pull requests by draft status. nil means no filtering. + // +optional + Draft *bool `json:"draft,omitempty"` + + // Author filters by the event sender's username. + // +optional + Author string `json:"author,omitempty"` +} + +// LinearWebhook configures webhook-driven task spawning from Linear events. +type LinearWebhook struct { + // Types is the list of Linear resource types to listen for. + // e.g., "Issue", "Comment", "Project" + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Types []string `json:"types"` + + // Filters refine which events trigger tasks (OR semantics). + // If empty, all events matching the Types list trigger tasks. + // +optional + Filters []LinearWebhookFilter `json:"filters,omitempty"` +} + +// LinearWebhookFilter defines criteria for matching a Linear webhook event. +type LinearWebhookFilter struct { + // Type is the Linear resource type this filter applies to. + // +kubebuilder:validation:Required + Type string `json:"type"` + + // Action filters by webhook action ("create", "update", "remove"). + // +optional + Action string `json:"action,omitempty"` + + // States filters by Linear workflow state names (e.g., "Todo", "In Progress"). + // +optional + States []string `json:"states,omitempty"` + + // Labels requires the issue to have all of these labels. + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels excludes issues with any of these labels. + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` +} + // TaskTemplateMetadata holds optional labels and annotations for spawned Tasks. type TaskTemplateMetadata struct { // Labels are merged into the spawned Task's labels. Values support Go diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 536ad3b2..215bb123 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -328,6 +328,58 @@ func (in *GitHubReporting) DeepCopy() *GitHubReporting { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubWebhook) DeepCopyInto(out *GitHubWebhook) { + *out = *in + if in.Events != nil { + in, out := &in.Events, &out.Events + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]GitHubWebhookFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubWebhook. +func (in *GitHubWebhook) DeepCopy() *GitHubWebhook { + if in == nil { + return nil + } + out := new(GitHubWebhook) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubWebhookFilter) DeepCopyInto(out *GitHubWebhookFilter) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Draft != nil { + in, out := &in.Draft, &out.Draft + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubWebhookFilter. +func (in *GitHubWebhookFilter) DeepCopy() *GitHubWebhookFilter { + if in == nil { + return nil + } + out := new(GitHubWebhookFilter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitRemote) DeepCopyInto(out *GitRemote) { *out = *in @@ -359,6 +411,63 @@ func (in *Jira) DeepCopy() *Jira { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinearWebhook) DeepCopyInto(out *LinearWebhook) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]LinearWebhookFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinearWebhook. +func (in *LinearWebhook) DeepCopy() *LinearWebhook { + if in == nil { + return nil + } + out := new(LinearWebhook) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinearWebhookFilter) DeepCopyInto(out *LinearWebhookFilter) { + *out = *in + if in.States != nil { + in, out := &in.States, &out.States + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinearWebhookFilter. +func (in *LinearWebhookFilter) DeepCopy() *LinearWebhookFilter { + if in == nil { + return nil + } + out := new(LinearWebhookFilter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = *in @@ -878,6 +987,16 @@ func (in *When) DeepCopyInto(out *When) { *out = new(Jira) **out = **in } + if in.GitHubWebhook != nil { + in, out := &in.GitHubWebhook, &out.GitHubWebhook + *out = new(GitHubWebhook) + (*in).DeepCopyInto(*out) + } + if in.LinearWebhook != nil { + in, out := &in.LinearWebhook, &out.LinearWebhook + *out = new(LinearWebhook) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new When. diff --git a/cmd/kelos-webhook-server/Dockerfile b/cmd/kelos-webhook-server/Dockerfile new file mode 100644 index 00000000..f0a36f11 --- /dev/null +++ b/cmd/kelos-webhook-server/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY bin/kelos-webhook-server . +USER 65532:65532 +ENTRYPOINT ["/kelos-webhook-server"] diff --git a/cmd/kelos-webhook-server/main.go b/cmd/kelos-webhook-server/main.go new file mode 100644 index 00000000..8e71f000 --- /dev/null +++ b/cmd/kelos-webhook-server/main.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "time" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/logging" + "github.com/kelos-dev/kelos/internal/webhook" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) +} + +func main() { + var ( + source string + port int + namespace string + allNamespaces bool + metricsAddr string + healthAddr string + ) + + flag.StringVar(&source, "source", "", "Webhook source type (required): github, linear") + flag.IntVar(&port, "port", 8080, "Port to listen for webhooks on") + flag.StringVar(&namespace, "namespace", "", "Namespace to watch for TaskSpawners (default: all namespaces)") + flag.BoolVar(&allNamespaces, "all-namespaces", false, "Watch TaskSpawners in all namespaces") + flag.StringVar(&metricsAddr, "metrics-bind-address", ":9090", "Address for Prometheus metrics") + flag.StringVar(&healthAddr, "health-probe-bind-address", ":8081", "Address for health probes") + + opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) + flag.Parse() + + if err := applyVerbosity(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + logger := zap.New(zap.UseFlagOptions(opts)) + ctrl.SetLogger(logger) + log := ctrl.Log.WithName("webhook-server") + + // Validate required flags + if source == "" { + log.Error(fmt.Errorf("--source is required"), "Invalid flags") + os.Exit(1) + } + + if source != "github" && source != "linear" { + log.Error(fmt.Errorf("--source must be github or linear, got %q", source), "Invalid flags") + os.Exit(1) + } + + // Handle namespace configuration + if namespace != "" && allNamespaces { + log.Error(fmt.Errorf("conflicting flags"), "--namespace and --all-namespaces cannot both be set") + os.Exit(1) + } + if namespace == "" && !allNamespaces { + allNamespaces = true + log.Info("No namespace specified, watching all namespaces") + } + + secretBytes := []byte(os.Getenv("WEBHOOK_SECRET")) + if len(secretBytes) == 0 { + log.Info("No WEBHOOK_SECRET provided, webhook signature validation will be disabled") + } + + cfg, err := ctrl.GetConfig() + if err != nil { + log.Error(err, "Unable to get kubeconfig") + os.Exit(1) + } + + // Setup cache options for namespace filtering + cacheOpts := cache.Options{} + if !allNamespaces { + cacheOpts.DefaultNamespaces = map[string]cache.Config{ + namespace: {}, + } + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + HealthProbeBindAddress: healthAddr, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + Cache: cacheOpts, + // Disable leader election since we're stateless + LeaderElection: false, + }) + if err != nil { + log.Error(err, "Unable to create manager") + os.Exit(1) + } + + handler := &webhook.Handler{ + Client: mgr.GetClient(), + Log: log.WithName("webhook").WithValues("source", source), + Source: source, + Secret: secretBytes, + } + + // Register health and readiness endpoints + if err := mgr.AddHealthzCheck("healthz", func(req *http.Request) error { + return nil // Always healthy + }); err != nil { + log.Error(err, "Unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", func(req *http.Request) error { + var list kelosv1alpha1.TaskSpawnerList + if err := mgr.GetClient().List(req.Context(), &list, client.Limit(1)); err != nil { + return fmt.Errorf("unable to list TaskSpawners: %w", err) + } + return nil + }); err != nil { + log.Error(err, "Unable to set up readiness check") + os.Exit(1) + } + + // Register webhook HTTP server as a Runnable + srv := &webhookServer{ + handler: handler, + port: port, + log: log, + } + if err := mgr.Add(srv); err != nil { + log.Error(err, "Unable to add webhook server runnable") + os.Exit(1) + } + + log.Info("Starting webhook server", + "source", source, + "port", port, + "namespace", namespace, + "allNamespaces", allNamespaces, + "secretConfigured", len(secretBytes) > 0) + + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + log.Error(err, "Manager exited with error") + os.Exit(1) + } +} + +// webhookServer implements manager.Runnable for the HTTP webhook listener. +type webhookServer struct { + handler *webhook.Handler + port int + log logr.Logger +} + +// Start starts the HTTP server and blocks until the context is cancelled. +func (s *webhookServer) Start(ctx context.Context) error { + mux := http.NewServeMux() + + // Main webhook endpoint + mux.Handle(fmt.Sprintf("/webhook/%s", s.handler.Source), s.handler) + + // Health endpoints (also available via manager's probe server) + mux.Handle("/healthz", webhook.HealthHandler()) + mux.Handle("/readyz", webhook.ReadyHandler(s.handler.Client)) + + // Root endpoint with basic info + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"service":"kelos-webhook-server","source":"%s","ready":true}`, s.handler.Source) + }) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: mux, + // Security settings + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in a goroutine + go func() { + s.log.Info("HTTP server listening", "port", s.port, "source", s.handler.Source) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.log.Error(err, "HTTP server error") + } + }() + + // Wait for shutdown signal + <-ctx.Done() + s.log.Info("Shutting down HTTP server") + + // Graceful shutdown with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + s.log.Error(err, "HTTP server shutdown error") + return err + } + + return nil +} + +// NeedLeaderElection returns false since the webhook server is stateless. +func (s *webhookServer) NeedLeaderElection() bool { + return false +} diff --git a/examples/10-taskspawner-github-webhook/README.md b/examples/10-taskspawner-github-webhook/README.md new file mode 100644 index 00000000..9cb0f386 --- /dev/null +++ b/examples/10-taskspawner-github-webhook/README.md @@ -0,0 +1,74 @@ +# GitHub Webhook TaskSpawner + +This example shows how to configure a TaskSpawner that reacts to GitHub webhook +events in real time instead of polling the GitHub API. + +## How It Works + +A global webhook server (`kelos-webhook-server --source=github`) watches all +TaskSpawners with a `githubWebhook` source. When GitHub sends a webhook, the +server matches it against configured filters and creates Tasks directly. + +## Setup + +### 1. Enable the webhook server in your Helm values + +```yaml +webhookServer: + sources: + github: + enabled: true + secretName: github-webhook-secret + ingress: + enabled: true + host: webhooks.example.com +``` + +### 2. Create the webhook secret + +```bash +kubectl create secret generic github-webhook-secret \ + --namespace kelos-system \ + --from-literal=webhook-secret=YOUR_SECRET_HERE +``` + +### 3. Configure the GitHub webhook + +In your GitHub repository settings, add a webhook: +- **Payload URL**: `https://webhooks.example.com/webhook/github` +- **Content type**: `application/json` +- **Secret**: The same value used in step 2 +- **Events**: Select the events your TaskSpawners listen for (e.g., Issue comments, Pull request reviews) + +### 4. Apply the TaskSpawner + +```bash +kubectl apply -f taskspawner.yaml +``` + +## Filter Reference + +Filters use OR semantics — if any filter matches, a task is created. + +| Field | Description | Example | +|-------|-------------|---------| +| `event` | GitHub event type (required) | `issue_comment` | +| `action` | Webhook action | `created`, `opened`, `submitted` | +| `bodyContains` | Substring match on comment/review body | `/fix` | +| `labels` | Require all listed labels | `["bug", "priority/high"]` | +| `state` | Issue/PR state | `open`, `closed` | +| `branch` | Push event branch (supports globs) | `main`, `release-*` | +| `draft` | PR draft status | `true`, `false` | +| `author` | Event sender username | `admin` | + +## Template Variables + +In addition to the standard template variables, webhook-sourced tasks have: + +| Variable | Description | +|----------|-------------| +| `{{.Event}}` | GitHub event type (e.g., `issue_comment`) | +| `{{.Action}}` | Webhook action (e.g., `created`) | +| `{{.Sender}}` | Username who triggered the event | +| `{{.Ref}}` | Git ref (push events) | +| `{{.Payload.field.sub}}` | Access any field in the raw webhook payload | diff --git a/examples/10-taskspawner-github-webhook/taskspawner.yaml b/examples/10-taskspawner-github-webhook/taskspawner.yaml new file mode 100644 index 00000000..b6a6c324 --- /dev/null +++ b/examples/10-taskspawner-github-webhook/taskspawner.yaml @@ -0,0 +1,36 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: pr-fix-command +spec: + maxConcurrency: 3 + when: + githubWebhook: + events: + - issue_comment + - pull_request_review + filters: + - event: issue_comment + action: created + bodyContains: "/fix" + - event: pull_request_review + action: submitted + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + branch: "kelos-fix-{{.Number}}" + promptTemplate: | + {{if eq .Event "issue_comment"}} + A user commented on {{.Kind}} #{{.Number}}: "{{.Body}}" + Please address the request and create a PR with the fix. + {{else if eq .Event "pull_request_review"}} + A review on PR #{{.Number}} requested changes. + Review body: "{{.Body}}" + Please address the review feedback. + {{end}} + ttlSecondsAfterFinished: 3600 diff --git a/examples/11-taskspawner-linear-webhook/README.md b/examples/11-taskspawner-linear-webhook/README.md new file mode 100644 index 00000000..377b41e7 --- /dev/null +++ b/examples/11-taskspawner-linear-webhook/README.md @@ -0,0 +1,75 @@ +# Linear Webhook TaskSpawner + +This example shows how to configure a TaskSpawner that reacts to Linear webhook +events in real time, creating tasks when new issues match specific criteria. + +## How It Works + +A global webhook server (`kelos-webhook-server --source=linear`) watches all +TaskSpawners with a `linearWebhook` source. When Linear sends a webhook, the +server matches it against configured filters and creates Tasks directly. + +## Setup + +### 1. Enable the webhook server in your Helm values + +```yaml +webhookServer: + sources: + linear: + enabled: true + secretName: linear-webhook-secret + ingress: + enabled: true + host: webhooks.example.com +``` + +### 2. Create the webhook secret + +```bash +kubectl create secret generic linear-webhook-secret \ + --namespace kelos-system \ + --from-literal=webhook-secret=YOUR_SECRET_HERE +``` + +### 3. Configure the Linear webhook + +In your Linear team settings, add a webhook: +- **URL**: `https://webhooks.example.com/webhook/linear` +- **Secret**: The same value used in step 2 +- **Resource types**: Select the types your TaskSpawners listen for (e.g., Issue) +- **Events**: Create, Update + +### 4. Apply the TaskSpawner + +```bash +kubectl apply -f taskspawner.yaml +``` + +## Filter Reference + +Filters use OR semantics — if any filter matches, a task is created. + +| Field | Description | Example | +|-------|-------------|---------| +| `type` | Linear resource type (required) | `Issue`, `Comment`, `Project` | +| `action` | Webhook action | `create`, `update`, `remove` | +| `states` | Issue workflow states | `["Todo", "In Progress"]` | +| `labels` | Require all listed labels | `["bug", "urgent"]` | +| `excludeLabels` | Exclude issues with any of these labels | `["wontfix"]` | + +## Template Variables + +In addition to the standard template variables, webhook-sourced tasks have: + +| Variable | Description | +|----------|-------------| +| `{{.Event}}` | Linear resource type (e.g., `Issue`) | +| `{{.Action}}` | Webhook action (e.g., `create`) | +| `{{.ID}}` | Linear issue ID | +| `{{.Title}}` | Issue title | +| `{{.Body}}` | Issue description | +| `{{.URL}}` | Linear issue URL | +| `{{.State}}` | Current workflow state | +| `{{.Labels}}` | Comma-separated labels | +| `{{.Payload.field.sub}}` | Access any field in the raw webhook payload | \ No newline at end of file diff --git a/examples/11-taskspawner-linear-webhook/taskspawner.yaml b/examples/11-taskspawner-linear-webhook/taskspawner.yaml new file mode 100644 index 00000000..6d8ccfa4 --- /dev/null +++ b/examples/11-taskspawner-linear-webhook/taskspawner.yaml @@ -0,0 +1,33 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: linear-bug-handler +spec: + maxConcurrency: 2 + when: + linearWebhook: + types: + - Issue + filters: + - type: Issue + action: create + labels: + - bug + states: + - Todo + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + branch: "kelos-linear-{{.ID}}" + promptTemplate: | + New bug filed in Linear: "{{.Title}}" + Description: {{.Body}} + URL: {{.URL}} + + Please investigate and fix this bug. Run tests to verify the fix. + ttlSecondsAfterFinished: 3600 diff --git a/examples/README.md b/examples/README.md index fe07596d..bc8b172c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,8 @@ Ready-to-use patterns and YAML manifests for orchestrating AI agents with Kelos. | [07-task-pipeline](07-task-pipeline/) | Chain Tasks with `dependsOn` and pass results between stages | | [08-task-with-kelos-skill](08-task-with-kelos-skill/) | Give an agent the Kelos skill for authoring and debugging resources | | [09-bedrock-credentials](09-bedrock-credentials/) | Run an agent using AWS Bedrock with static credentials or IRSA | +| [10-taskspawner-github-webhook](10-taskspawner-github-webhook/) | React to GitHub events in real time using webhooks | +| [11-taskspawner-linear-webhook](11-taskspawner-linear-webhook/) | React to Linear events in real time using webhooks | ## How to Use diff --git a/go.mod b/go.mod index ffce2050..2dd39bac 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-logr/logr v1.4.3 + github.com/google/go-github/v66 v66.0.0 github.com/google/uuid v1.6.0 github.com/google/yamlfmt v0.21.0 github.com/onsi/ginkgo/v2 v2.27.2 @@ -54,6 +55,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/renameio/v2 v2.0.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect diff --git a/go.sum b/go.sum index e03035e3..7bd5416c 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,13 @@ github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= +github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -274,6 +279,7 @@ golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnps golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= diff --git a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml index 25d9875a..0c138edd 100644 --- a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml @@ -792,6 +792,69 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: |- + GitHubWebhook configures webhook-driven task spawning from GitHub events. + A global webhook server watches TaskSpawners with this field set and + creates Tasks when incoming webhooks match the configured filters. + properties: + events: + description: |- + Events is the list of GitHub event types to listen for. + e.g., "issue_comment", "pull_request_review", "push", "issues", "pull_request" + items: + type: string + minItems: 1 + type: array + filters: + description: |- + Filters refine which events trigger tasks. If multiple filters are + defined, any match triggers a task (OR semantics). If empty, all + events in the Events list trigger tasks. + items: + description: GitHubWebhookFilter defines criteria for matching + a GitHub webhook event. + properties: + action: + description: Action filters by webhook action (e.g., + "created", "opened", "submitted"). + type: string + author: + description: Author filters by the event sender's username. + type: string + bodyContains: + description: BodyContains filters by substring match + on the comment or review body. + type: string + branch: + description: Branch filters push events by branch name + (supports glob patterns). + type: string + draft: + description: Draft filters pull requests by draft status. + nil means no filtering. + type: boolean + event: + description: Event is the GitHub event type this filter + applies to. + type: string + labels: + description: Labels requires the issue or PR to have + all of these labels. + items: + type: string + type: array + state: + description: State filters by issue or PR state ("open", + "closed"). + type: string + required: + - event + type: object + type: array + required: + - events + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -831,6 +894,61 @@ spec: - project - secretRef type: object + linearWebhook: + description: |- + LinearWebhook configures webhook-driven task spawning from Linear events. + A global webhook server watches TaskSpawners with this field set and + creates Tasks when incoming webhooks match the configured filters. + properties: + filters: + description: |- + Filters refine which events trigger tasks (OR semantics). + If empty, all events matching the Types list trigger tasks. + items: + description: LinearWebhookFilter defines criteria for matching + a Linear webhook event. + properties: + action: + description: Action filters by webhook action ("create", + "update", "remove"). + type: string + excludeLabels: + description: ExcludeLabels excludes issues with any + of these labels. + items: + type: string + type: array + labels: + description: Labels requires the issue to have all of + these labels. + items: + type: string + type: array + states: + description: States filters by Linear workflow state + names (e.g., "Todo", "In Progress"). + items: + type: string + type: array + type: + description: Type is the Linear resource type this filter + applies to. + type: string + required: + - type + type: object + type: array + types: + description: |- + Types is the list of Linear resource types to listen for. + e.g., "Issue", "Comment", "Project" + items: + type: string + minItems: 1 + type: array + required: + - types + type: object type: object required: - taskTemplate diff --git a/internal/manifests/charts/kelos/templates/rbac.yaml b/internal/manifests/charts/kelos/templates/rbac.yaml index 2d1a6e1d..261b9f15 100644 --- a/internal/manifests/charts/kelos/templates/rbac.yaml +++ b/internal/manifests/charts/kelos/templates/rbac.yaml @@ -154,6 +154,29 @@ rules: - watch --- apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kelos-webhook-server-role +rules: + - apiGroups: + - kelos.dev + resources: + - taskspawners + verbs: + - get + - list + - watch + - apiGroups: + - kelos.dev + resources: + - tasks + verbs: + - create + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: kelos-controller-rolebinding diff --git a/internal/manifests/charts/kelos/templates/webhook-ingress.yaml b/internal/manifests/charts/kelos/templates/webhook-ingress.yaml new file mode 100644 index 00000000..113f9f10 --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.webhookServer.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kelos-webhook-server + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server + {{- with .Values.webhookServer.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.webhookServer.ingress.className }} + ingressClassName: {{ .Values.webhookServer.ingress.className }} + {{- end }} + rules: + - {{- if .Values.webhookServer.ingress.host }} + host: {{ .Values.webhookServer.ingress.host }} + {{- end }} + http: + paths: + {{- range $name, $source := .Values.webhookServer.sources }} + {{- if $source.enabled }} + - path: /webhook/{{ $name }} + pathType: Prefix + backend: + service: + name: kelos-webhook-server-{{ $name }} + port: + name: webhook + {{- end }} + {{- end }} +{{- end }} diff --git a/internal/manifests/charts/kelos/templates/webhook-server.yaml b/internal/manifests/charts/kelos/templates/webhook-server.yaml new file mode 100644 index 00000000..81d93d86 --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-server.yaml @@ -0,0 +1,108 @@ +{{- range $name, $source := .Values.webhookServer.sources }} +{{- if $source.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-server-{{ $name }} + namespace: kelos-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-server-{{ $name }} + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server-{{ $name }} +spec: + replicas: {{ $source.replicas | default 1 }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server-{{ $name }} + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server-{{ $name }} + spec: + serviceAccountName: kelos-webhook-server-{{ $name }} + securityContext: + runAsNonRoot: true + containers: + - name: webhook-server + image: {{ $.Values.webhookServer.image }}{{- if $.Values.image.tag }}:{{ $.Values.image.tag }}{{- end }} + {{- if $.Values.image.pullPolicy }} + imagePullPolicy: {{ $.Values.image.pullPolicy }} + {{- end }} + args: + - --source={{ $name }} + {{- if $source.secretName }} + env: + - name: WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ $source.secretName }} + key: webhook-secret + {{- end }} + ports: + - name: webhook + containerPort: 8080 + protocol: TCP + - name: metrics + containerPort: 9090 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-server-{{ $name }} + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server-{{ $name }} +spec: + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-server-{{ $name }} + ports: + - name: webhook + port: 80 + targetPort: webhook + protocol: TCP +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kelos-webhook-server-{{ $name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kelos-webhook-server-role +subjects: + - kind: ServiceAccount + name: kelos-webhook-server-{{ $name }} + namespace: kelos-system +{{- end }} +{{- end }} diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index 6e389b98..4aa4dec5 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -29,3 +29,19 @@ controller: resources: requests: {} limits: {} +webhookServer: + image: ghcr.io/kelos-dev/kelos-webhook-server + sources: + github: + enabled: false + replicas: 1 + secretName: "" + linear: + enabled: false + replicas: 1 + secretName: "" + ingress: + enabled: false + className: "" + host: "" + annotations: {} diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 96f24feb..a97d3873 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -1471,6 +1471,69 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: |- + GitHubWebhook configures webhook-driven task spawning from GitHub events. + A global webhook server watches TaskSpawners with this field set and + creates Tasks when incoming webhooks match the configured filters. + properties: + events: + description: |- + Events is the list of GitHub event types to listen for. + e.g., "issue_comment", "pull_request_review", "push", "issues", "pull_request" + items: + type: string + minItems: 1 + type: array + filters: + description: |- + Filters refine which events trigger tasks. If multiple filters are + defined, any match triggers a task (OR semantics). If empty, all + events in the Events list trigger tasks. + items: + description: GitHubWebhookFilter defines criteria for matching + a GitHub webhook event. + properties: + action: + description: Action filters by webhook action (e.g., + "created", "opened", "submitted"). + type: string + author: + description: Author filters by the event sender's username. + type: string + bodyContains: + description: BodyContains filters by substring match + on the comment or review body. + type: string + branch: + description: Branch filters push events by branch name + (supports glob patterns). + type: string + draft: + description: Draft filters pull requests by draft status. + nil means no filtering. + type: boolean + event: + description: Event is the GitHub event type this filter + applies to. + type: string + labels: + description: Labels requires the issue or PR to have + all of these labels. + items: + type: string + type: array + state: + description: State filters by issue or PR state ("open", + "closed"). + type: string + required: + - event + type: object + type: array + required: + - events + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -1510,6 +1573,61 @@ spec: - project - secretRef type: object + linearWebhook: + description: |- + LinearWebhook configures webhook-driven task spawning from Linear events. + A global webhook server watches TaskSpawners with this field set and + creates Tasks when incoming webhooks match the configured filters. + properties: + filters: + description: |- + Filters refine which events trigger tasks (OR semantics). + If empty, all events matching the Types list trigger tasks. + items: + description: LinearWebhookFilter defines criteria for matching + a Linear webhook event. + properties: + action: + description: Action filters by webhook action ("create", + "update", "remove"). + type: string + excludeLabels: + description: ExcludeLabels excludes issues with any + of these labels. + items: + type: string + type: array + labels: + description: Labels requires the issue to have all of + these labels. + items: + type: string + type: array + states: + description: States filters by Linear workflow state + names (e.g., "Todo", "In Progress"). + items: + type: string + type: array + type: + description: Type is the Linear resource type this filter + applies to. + type: string + required: + - type + type: object + type: array + types: + description: |- + Types is the list of Linear resource types to listen for. + e.g., "Issue", "Comment", "Project" + items: + type: string + minItems: 1 + type: array + required: + - types + type: object type: object required: - taskTemplate diff --git a/internal/taskbuilder/builder.go b/internal/taskbuilder/builder.go new file mode 100644 index 00000000..c228d2a2 --- /dev/null +++ b/internal/taskbuilder/builder.go @@ -0,0 +1,325 @@ +package taskbuilder + +import ( + "bytes" + "fmt" + "strconv" + "strings" + "text/template" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/reporting" + "github.com/kelos-dev/kelos/internal/source" +) + +// TaskBuilder creates Task resources from TaskSpawner templates. +// It handles template rendering, metadata generation, and annotation management +// for both traditional sources (GitHub Issues, Cron) and webhook sources. +type TaskBuilder struct { + spawner *kelosv1alpha1.TaskSpawner +} + +// NewTaskBuilder creates a new TaskBuilder for the given TaskSpawner. +func NewTaskBuilder(spawner *kelosv1alpha1.TaskSpawner) *TaskBuilder { + return &TaskBuilder{spawner: spawner} +} + +// TaskInput represents the data used to create a Task. It can come from +// traditional sources (WorkItem) or webhook events (map[string]interface{}). +type TaskInput interface { + // GetID returns a unique identifier for this work item + GetID() string + // GetTemplateData returns the data structure used for template rendering + GetTemplateData() interface{} + // GetSourceAnnotations returns source-specific annotations to add to the Task + GetSourceAnnotations() map[string]string +} + +// WorkItemInput adapts the existing source.WorkItem to the TaskInput interface. +type WorkItemInput struct { + Item source.WorkItem +} + +func (w WorkItemInput) GetID() string { + return w.Item.ID +} + +func (w WorkItemInput) GetTemplateData() interface{} { + // Convert WorkItem to the template data format expected by source.RenderTemplate + kind := w.Item.Kind + if kind == "" { + kind = "Issue" + } + + return struct { + ID string + Number int + Title string + Body string + URL string + Labels string + Comments string + Kind string + Branch string + ReviewState string + ReviewComments string + Time string + Schedule string + }{ + ID: w.Item.ID, + Number: w.Item.Number, + Title: w.Item.Title, + Body: w.Item.Body, + URL: w.Item.URL, + Labels: strings.Join(w.Item.Labels, ", "), + Comments: w.Item.Comments, + Kind: kind, + Branch: w.Item.Branch, + ReviewState: w.Item.ReviewState, + ReviewComments: w.Item.ReviewComments, + Time: w.Item.Time, + Schedule: w.Item.Schedule, + } +} + +func (w WorkItemInput) GetSourceAnnotations() map[string]string { + return getSourceAnnotations(w.Item, w.Item.Kind) +} + +// WebhookInput adapts webhook template data to the TaskInput interface. +type WebhookInput struct { + ID string + TemplateVars map[string]interface{} + DeliveryID string // For idempotency (e.g., X-GitHub-Delivery header) + SourceType string // "github", "linear" + EventType string // "issue_comment", "push", etc. +} + +func (wh WebhookInput) GetID() string { + return wh.ID +} + +func (wh WebhookInput) GetTemplateData() interface{} { + return wh.TemplateVars +} + +func (wh WebhookInput) GetSourceAnnotations() map[string]string { + annotations := make(map[string]string) + + // Add webhook-specific annotations for idempotency and auditing + annotations["kelos.dev/webhook-source"] = wh.SourceType + annotations["kelos.dev/webhook-event"] = wh.EventType + if wh.DeliveryID != "" { + annotations["kelos.dev/webhook-delivery"] = wh.DeliveryID + } + + // Add GitHub reporting annotations if this is a GitHub webhook + // with issue/PR data for backward compatibility with existing reporting + if wh.SourceType == "github" { + if kind, ok := wh.TemplateVars["Kind"].(string); ok { + if kind == "Issue" || kind == "PR" { + sourceKind := "issue" + if kind == "PR" { + sourceKind = "pull-request" + } + annotations[reporting.AnnotationSourceKind] = sourceKind + + if number, ok := wh.TemplateVars["Number"].(int); ok && number > 0 { + annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(number) + } + + // Note: GitHub reporting enabled flag would need to be passed separately + // or determined from the TaskSpawner configuration + } + } + } + + return annotations +} + +// BuildTask creates a Task from the TaskSpawner template and input data. +func (tb *TaskBuilder) BuildTask(input TaskInput) (*kelosv1alpha1.Task, error) { + // Generate task name + taskName := fmt.Sprintf("%s-%s", tb.spawner.Name, input.GetID()) + + // Render prompt template + prompt, err := tb.renderTemplate(tb.spawner.Spec.TaskTemplate.PromptTemplate, input.GetTemplateData()) + if err != nil { + return nil, fmt.Errorf("rendering prompt template: %w", err) + } + + // Render task metadata (labels and annotations from TaskTemplate.Metadata) + renderedLabels, renderedAnnotations, err := tb.renderTaskTemplateMetadata(input.GetTemplateData()) + if err != nil { + return nil, fmt.Errorf("rendering task template metadata: %w", err) + } + + // Build labels (user labels + required kelos.dev/taskspawner label) + labels := make(map[string]string) + for k, v := range renderedLabels { + labels[k] = v + } + labels["kelos.dev/taskspawner"] = tb.spawner.Name + + // Build annotations (user annotations + source annotations) + annotations := mergeStringMaps(renderedAnnotations, input.GetSourceAnnotations()) + + // Create Task spec + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: tb.spawner.Namespace, + Labels: labels, + Annotations: annotations, + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: tb.spawner.Spec.TaskTemplate.Type, + Prompt: prompt, + Credentials: tb.spawner.Spec.TaskTemplate.Credentials, + Model: tb.spawner.Spec.TaskTemplate.Model, + Image: tb.spawner.Spec.TaskTemplate.Image, + TTLSecondsAfterFinished: tb.spawner.Spec.TaskTemplate.TTLSecondsAfterFinished, + PodOverrides: tb.spawner.Spec.TaskTemplate.PodOverrides, + }, + } + + // Set optional fields + if tb.spawner.Spec.TaskTemplate.WorkspaceRef != nil { + task.Spec.WorkspaceRef = tb.spawner.Spec.TaskTemplate.WorkspaceRef + } + if tb.spawner.Spec.TaskTemplate.AgentConfigRef != nil { + task.Spec.AgentConfigRef = tb.spawner.Spec.TaskTemplate.AgentConfigRef + } + if len(tb.spawner.Spec.TaskTemplate.DependsOn) > 0 { + task.Spec.DependsOn = tb.spawner.Spec.TaskTemplate.DependsOn + } + + // Render branch template if configured + if tb.spawner.Spec.TaskTemplate.Branch != "" { + branch, err := tb.renderTemplate(tb.spawner.Spec.TaskTemplate.Branch, input.GetTemplateData()) + if err != nil { + return nil, fmt.Errorf("rendering branch template: %w", err) + } + task.Spec.Branch = branch + } + + // Set upstream repo if configured + if tb.spawner.Spec.TaskTemplate.UpstreamRepo != "" { + task.Spec.UpstreamRepo = tb.spawner.Spec.TaskTemplate.UpstreamRepo + } + + return task, nil +} + +// renderTemplate renders a Go text/template string with the given data. +func (tb *TaskBuilder) renderTemplate(tmplStr string, data interface{}) (string, error) { + if tmplStr == "" { + return "", nil + } + + tmpl, err := template.New("tmpl").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("parsing template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return buf.String(), nil +} + +// renderTaskTemplateMetadata renders taskTemplate.metadata label and annotation +// values using the template data. +func (tb *TaskBuilder) renderTaskTemplateMetadata(data interface{}) (labels map[string]string, annotations map[string]string, err error) { + meta := tb.spawner.Spec.TaskTemplate.Metadata + if meta == nil { + return nil, nil, nil + } + + if len(meta.Labels) > 0 { + labels = make(map[string]string) + for k, v := range meta.Labels { + rendered, err := tb.renderTemplate(v, data) + if err != nil { + return nil, nil, fmt.Errorf("label %q: %w", k, err) + } + labels[k] = rendered + } + } + + if len(meta.Annotations) > 0 { + annotations = make(map[string]string) + for k, v := range meta.Annotations { + rendered, err := tb.renderTemplate(v, data) + if err != nil { + return nil, nil, fmt.Errorf("annotation %q: %w", k, err) + } + annotations[k] = rendered + } + } + + return labels, annotations, nil +} + +// mergeStringMaps returns a new map with keys from base, then keys from overlay +// overwriting on duplicate keys. +func mergeStringMaps(base, overlay map[string]string) map[string]string { + if len(base) == 0 && len(overlay) == 0 { + return nil + } + out := make(map[string]string) + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + out[k] = v + } + return out +} + +// getSourceAnnotations returns annotations that stamp source metadata +// onto a spawned Task for GitHub Issues/PRs. These annotations enable +// downstream consumers (such as the reporting watcher) to identify the +// originating issue or PR. +func getSourceAnnotations(item source.WorkItem, kind string) map[string]string { + // Only add GitHub source annotations for Issue/PR types + if kind != "Issue" && kind != "PR" { + return nil + } + + sourceKind := "issue" + if kind == "PR" { + sourceKind = "pull-request" + } + + annotations := map[string]string{ + reporting.AnnotationSourceKind: sourceKind, + reporting.AnnotationSourceNumber: strconv.Itoa(item.Number), + } + + return annotations +} + +// BuildTaskFromWorkItem creates a Task from a WorkItem (for existing sources). +// This is a convenience method that wraps BuildTask with WorkItemInput. +func (tb *TaskBuilder) BuildTaskFromWorkItem(item source.WorkItem) (*kelosv1alpha1.Task, error) { + input := WorkItemInput{Item: item} + return tb.BuildTask(input) +} + +// BuildTaskFromWebhook creates a Task from webhook template data. +// This is a convenience method that wraps BuildTask with WebhookInput. +func (tb *TaskBuilder) BuildTaskFromWebhook(id string, templateVars map[string]interface{}, deliveryID, sourceType, eventType string) (*kelosv1alpha1.Task, error) { + input := WebhookInput{ + ID: id, + TemplateVars: templateVars, + DeliveryID: deliveryID, + SourceType: sourceType, + EventType: eventType, + } + return tb.BuildTask(input) +} diff --git a/internal/webhook/github_filter.go b/internal/webhook/github_filter.go new file mode 100644 index 00000000..b60b03c7 --- /dev/null +++ b/internal/webhook/github_filter.go @@ -0,0 +1,531 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/google/go-github/v66/github" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// GitHubEventData contains parsed GitHub webhook event data with common fields +// extracted for easy access in filters and templates. +type GitHubEventData struct { + Event string // GitHub event type (e.g., "issue_comment", "push") + Action string // Event action (e.g., "created", "opened") + Sender *github.User // User who triggered the event + Repository *github.Repository // Repository where the event occurred + RawEvent interface{} // Full parsed event struct from go-github + RawPayload map[string]interface{} // Raw JSON payload for template access +} + +// ParseGitHubWebhook parses a GitHub webhook payload using go-github and extracts +// common fields for filtering and template rendering. +func ParseGitHubWebhook(eventType string, payload []byte) (*GitHubEventData, error) { + // Parse using go-github for type safety and completeness + event, err := github.ParseWebHook(eventType, payload) + if err != nil { + return nil, fmt.Errorf("parsing GitHub webhook: %w", err) + } + + // Parse raw payload for template access to any field + var rawPayload map[string]interface{} + if err := json.Unmarshal(payload, &rawPayload); err != nil { + return nil, fmt.Errorf("parsing raw payload: %w", err) + } + + data := &GitHubEventData{ + Event: eventType, + RawEvent: event, + RawPayload: rawPayload, + } + + // Extract common fields based on event type + switch e := event.(type) { + case *github.IssueCommentEvent: + data.Action = getString(e.Action) + data.Sender = e.Sender + data.Repository = e.Repo + case *github.IssuesEvent: + data.Action = getString(e.Action) + data.Sender = e.Sender + data.Repository = e.Repo + case *github.PullRequestEvent: + data.Action = getString(e.Action) + data.Sender = e.Sender + data.Repository = e.Repo + case *github.PullRequestReviewEvent: + data.Action = getString(e.Action) + data.Sender = e.Sender + data.Repository = e.Repo + case *github.PullRequestReviewCommentEvent: + data.Action = getString(e.Action) + data.Sender = e.Sender + data.Repository = e.Repo + case *github.PushEvent: + data.Sender = e.Sender + // Convert PushEventRepository to Repository for consistency + if e.Repo != nil { + data.Repository = &github.Repository{ + Name: e.Repo.Name, + FullName: e.Repo.FullName, + HTMLURL: e.Repo.HTMLURL, + } + } + // Push events don't have an "action" field + default: + // Try to extract sender and repo from raw payload for unknown event types + if sender, ok := rawPayload["sender"].(map[string]interface{}); ok { + if login, ok := sender["login"].(string); ok { + data.Sender = &github.User{Login: &login} + } + } + if repo, ok := rawPayload["repository"].(map[string]interface{}); ok { + if name, ok := repo["name"].(string); ok { + data.Repository = &github.Repository{Name: &name} + } + } + if action, ok := rawPayload["action"].(string); ok { + data.Action = action + } + } + + return data, nil +} + +// MatchesGitHubEvent checks whether a GitHub webhook event matches a TaskSpawner's +// GitHubWebhook configuration. Returns true if the event should trigger a task. +func MatchesGitHubEvent(cfg *kelosv1alpha1.GitHubWebhook, eventType string, payload []byte) bool { + // Check if the event type is in the configured events list + eventMatched := false + for _, e := range cfg.Events { + if e == eventType { + eventMatched = true + break + } + } + if !eventMatched { + return false + } + + // Parse the webhook using go-github + data, err := ParseGitHubWebhook(eventType, payload) + if err != nil { + return false + } + + // If no filters are configured, all matching event types trigger + if len(cfg.Filters) == 0 { + return true + } + + // OR semantics: any matching filter triggers + for _, f := range cfg.Filters { + matches, err := MatchesGitHubFilter(f, data) + if err != nil { + return false + } + if matches { + return true + } + } + + return false +} + +// MatchesGitHubFilter evaluates whether a GitHub webhook event matches the given filter. +func MatchesGitHubFilter(filter kelosv1alpha1.GitHubWebhookFilter, data *GitHubEventData) (bool, error) { + // Event type must match (required field) + if filter.Event != data.Event { + return false, nil + } + + // Check action filter + if filter.Action != "" && filter.Action != data.Action { + return false, nil + } + + // Check author filter + if filter.Author != "" { + if data.Sender == nil || !strings.EqualFold(getString(data.Sender.Login), filter.Author) { + return false, nil + } + } + + // Event-specific filtering + switch e := data.RawEvent.(type) { + case *github.IssueCommentEvent: + return matchesIssueCommentEvent(filter, e) + case *github.IssuesEvent: + return matchesIssuesEvent(filter, e) + case *github.PullRequestEvent: + return matchesPullRequestEvent(filter, e) + case *github.PullRequestReviewEvent: + return matchesPullRequestReviewEvent(filter, e) + case *github.PullRequestReviewCommentEvent: + return matchesPullRequestReviewCommentEvent(filter, e) + case *github.PushEvent: + return matchesPushEvent(filter, e) + default: + // For unknown event types, only basic filters apply (event, action, author) + return true, nil + } +} + +func matchesIssueCommentEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.IssueCommentEvent) (bool, error) { + // Body contains filter + if filter.BodyContains != "" { + if event.Comment == nil || !strings.Contains(getString(event.Comment.Body), filter.BodyContains) { + return false, nil + } + } + + // Issue/PR labels filter + if len(filter.Labels) > 0 { + if event.Issue == nil { + return false, nil + } + if !hasAllLabels(event.Issue.Labels, filter.Labels) { + return false, nil + } + } + + // Issue/PR state filter + if filter.State != "" { + if event.Issue == nil || getString(event.Issue.State) != filter.State { + return false, nil + } + } + + // Draft filter (for PRs) + if filter.Draft != nil { + if event.Issue == nil || event.Issue.PullRequestLinks == nil { + // Not a PR, but draft filter specified + return false, nil + } + if event.Issue.Draft != nil && *event.Issue.Draft != *filter.Draft { + return false, nil + } + } + + return true, nil +} + +func matchesIssuesEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.IssuesEvent) (bool, error) { + // Labels filter + if len(filter.Labels) > 0 { + if event.Issue == nil { + return false, nil + } + if !hasAllLabels(event.Issue.Labels, filter.Labels) { + return false, nil + } + } + + // State filter + if filter.State != "" { + if event.Issue == nil || getString(event.Issue.State) != filter.State { + return false, nil + } + } + + return true, nil +} + +func matchesPullRequestEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.PullRequestEvent) (bool, error) { + // Labels filter + if len(filter.Labels) > 0 { + if event.PullRequest == nil { + return false, nil + } + if !hasAllLabels(event.PullRequest.Labels, filter.Labels) { + return false, nil + } + } + + // State filter + if filter.State != "" { + if event.PullRequest == nil || getString(event.PullRequest.State) != filter.State { + return false, nil + } + } + + // Draft filter + if filter.Draft != nil { + if event.PullRequest == nil { + return false, nil + } + if event.PullRequest.Draft != nil && *event.PullRequest.Draft != *filter.Draft { + return false, nil + } + } + + return true, nil +} + +func matchesPullRequestReviewEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.PullRequestReviewEvent) (bool, error) { + // Body contains filter + if filter.BodyContains != "" { + if event.Review == nil || !strings.Contains(getString(event.Review.Body), filter.BodyContains) { + return false, nil + } + } + + // PR labels filter + if len(filter.Labels) > 0 { + if event.PullRequest == nil { + return false, nil + } + if !hasAllLabels(event.PullRequest.Labels, filter.Labels) { + return false, nil + } + } + + // PR state filter + if filter.State != "" { + if event.PullRequest == nil || getString(event.PullRequest.State) != filter.State { + return false, nil + } + } + + // Draft filter + if filter.Draft != nil { + if event.PullRequest == nil { + return false, nil + } + if event.PullRequest.Draft != nil && *event.PullRequest.Draft != *filter.Draft { + return false, nil + } + } + + return true, nil +} + +func matchesPullRequestReviewCommentEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.PullRequestReviewCommentEvent) (bool, error) { + // Body contains filter + if filter.BodyContains != "" { + if event.Comment == nil || !strings.Contains(getString(event.Comment.Body), filter.BodyContains) { + return false, nil + } + } + + // PR labels filter + if len(filter.Labels) > 0 { + if event.PullRequest == nil { + return false, nil + } + if !hasAllLabels(event.PullRequest.Labels, filter.Labels) { + return false, nil + } + } + + // PR state filter + if filter.State != "" { + if event.PullRequest == nil || getString(event.PullRequest.State) != filter.State { + return false, nil + } + } + + // Draft filter + if filter.Draft != nil { + if event.PullRequest == nil { + return false, nil + } + if event.PullRequest.Draft != nil && *event.PullRequest.Draft != *filter.Draft { + return false, nil + } + } + + return true, nil +} + +func matchesPushEvent(filter kelosv1alpha1.GitHubWebhookFilter, event *github.PushEvent) (bool, error) { + // Branch filter + if filter.Branch != "" { + if event.Ref == nil { + return false, nil + } + // Extract branch name from ref (refs/heads/main -> main) + ref := getString(event.Ref) + branchName := strings.TrimPrefix(ref, "refs/heads/") + + // Support glob patterns + matched, err := filepath.Match(filter.Branch, branchName) + if err != nil { + return false, fmt.Errorf("invalid branch pattern %q: %w", filter.Branch, err) + } + if !matched { + return false, nil + } + } + + return true, nil +} + +// hasAllLabels checks if the issue/PR has all the required labels. +func hasAllLabels(issueLabels []*github.Label, requiredLabels []string) bool { + labelSet := make(map[string]bool) + for _, label := range issueLabels { + if label.Name != nil { + labelSet[*label.Name] = true + } + } + + for _, required := range requiredLabels { + if !labelSet[required] { + return false + } + } + return true +} + +// getString safely extracts a string from a GitHub SDK string pointer. +func getString(ptr *string) string { + if ptr == nil { + return "" + } + return *ptr +} + +// ExtractGitHubWorkItem extracts template variables from a GitHub webhook payload. +// Returns a map suitable for use in prompt template rendering. +func ExtractGitHubWorkItem(eventType string, payload []byte) map[string]interface{} { + // Parse the webhook using go-github + data, err := ParseGitHubWebhook(eventType, payload) + if err != nil { + return nil + } + + return ExtractTemplateVars(data) +} + +// ExtractTemplateVars extracts template variables from GitHub webhook data for use in +// promptTemplate and branch rendering. Returns variables compatible with existing +// template format plus new webhook-specific ones. +func ExtractTemplateVars(data *GitHubEventData) map[string]interface{} { + vars := map[string]interface{}{ + "Event": data.Event, + "Action": data.Action, + "Payload": data.RawPayload, // Full payload access for {{.Payload.field.sub}} + } + + // Add sender info + if data.Sender != nil { + vars["Sender"] = getString(data.Sender.Login) + } + + // Add ref for push events + if pushEvent, ok := data.RawEvent.(*github.PushEvent); ok && pushEvent.Ref != nil { + vars["Ref"] = getString(pushEvent.Ref) + } + + // Extract common fields based on event type for backward compatibility + switch e := data.RawEvent.(type) { + case *github.IssueCommentEvent: + if e.Issue != nil { + vars["ID"] = fmt.Sprintf("issue-%d", getInt(e.Issue.Number)) + vars["Number"] = getInt(e.Issue.Number) + vars["Title"] = getString(e.Issue.Title) + vars["Body"] = getString(e.Issue.Body) + vars["URL"] = getString(e.Issue.HTMLURL) + vars["Kind"] = "Issue" + if e.Issue.PullRequestLinks != nil { + vars["Kind"] = "PR" + } + vars["Labels"] = extractLabelNames(e.Issue.Labels) + } + if e.Comment != nil { + vars["Body"] = getString(e.Comment.Body) // Override with comment body + } + + case *github.IssuesEvent: + if e.Issue != nil { + vars["ID"] = fmt.Sprintf("issue-%d", getInt(e.Issue.Number)) + vars["Number"] = getInt(e.Issue.Number) + vars["Title"] = getString(e.Issue.Title) + vars["Body"] = getString(e.Issue.Body) + vars["URL"] = getString(e.Issue.HTMLURL) + vars["Kind"] = "Issue" + vars["Labels"] = extractLabelNames(e.Issue.Labels) + } + + case *github.PullRequestEvent: + if e.PullRequest != nil { + vars["ID"] = fmt.Sprintf("pr-%d", getInt(e.PullRequest.Number)) + vars["Number"] = getInt(e.PullRequest.Number) + vars["Title"] = getString(e.PullRequest.Title) + vars["Body"] = getString(e.PullRequest.Body) + vars["URL"] = getString(e.PullRequest.HTMLURL) + vars["Kind"] = "PR" + vars["Labels"] = extractLabelNames(e.PullRequest.Labels) + } + + case *github.PullRequestReviewEvent: + if e.PullRequest != nil { + vars["ID"] = fmt.Sprintf("pr-%d", getInt(e.PullRequest.Number)) + vars["Number"] = getInt(e.PullRequest.Number) + vars["Title"] = getString(e.PullRequest.Title) + vars["URL"] = getString(e.PullRequest.HTMLURL) + vars["Kind"] = "PR" + vars["Labels"] = extractLabelNames(e.PullRequest.Labels) + } + if e.Review != nil { + vars["Body"] = getString(e.Review.Body) // Review body, not PR body + } + + case *github.PullRequestReviewCommentEvent: + if e.PullRequest != nil { + vars["ID"] = fmt.Sprintf("pr-%d", getInt(e.PullRequest.Number)) + vars["Number"] = getInt(e.PullRequest.Number) + vars["Title"] = getString(e.PullRequest.Title) + vars["URL"] = getString(e.PullRequest.HTMLURL) + vars["Kind"] = "PR" + vars["Labels"] = extractLabelNames(e.PullRequest.Labels) + } + if e.Comment != nil { + vars["Body"] = getString(e.Comment.Body) // Comment body + } + + case *github.PushEvent: + // For push events, create a synthetic ID from the head commit + if e.HeadCommit != nil { + vars["ID"] = fmt.Sprintf("push-%s", getString(e.HeadCommit.ID)[:8]) + vars["Title"] = getString(e.HeadCommit.Message) + vars["Body"] = getString(e.HeadCommit.Message) + vars["URL"] = getString(e.HeadCommit.URL) + } else if e.After != nil { + commitID := getString(e.After) + if len(commitID) >= 8 { + vars["ID"] = fmt.Sprintf("push-%s", commitID[:8]) + } else { + vars["ID"] = fmt.Sprintf("push-%s", commitID) + } + } + vars["Kind"] = "Push" + vars["Number"] = 0 // Push events don't have numbers + } + + return vars +} + +// extractLabelNames extracts label names from GitHub label objects. +func extractLabelNames(labels []*github.Label) []string { + names := make([]string, 0, len(labels)) + for _, label := range labels { + if label.Name != nil { + names = append(names, *label.Name) + } + } + return names +} + +// getInt safely extracts an int from a GitHub SDK int pointer. +func getInt(ptr *int) int { + if ptr == nil { + return 0 + } + return *ptr +} diff --git a/internal/webhook/github_filter_test.go b/internal/webhook/github_filter_test.go new file mode 100644 index 00000000..326f2d7f --- /dev/null +++ b/internal/webhook/github_filter_test.go @@ -0,0 +1,330 @@ +package webhook + +import ( + "encoding/json" + "testing" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func boolPtr(b bool) *bool { return &b } + +func TestMatchesGitHubEvent(t *testing.T) { + tests := []struct { + name string + cfg *kelosv1alpha1.GitHubWebhook + eventType string + payload interface{} + want bool + }{ + { + name: "event type not in list", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + }, + eventType: "push", + payload: map[string]interface{}{}, + want: false, + }, + { + name: "event type matches, no filters", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues", "push"}, + }, + eventType: "push", + payload: map[string]interface{}{}, + want: true, + }, + { + name: "issue_comment with action filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Action: "created"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "user1"}, + "issue": map[string]interface{}{"number": 42, "state": "open"}, + }, + want: true, + }, + { + name: "issue_comment action mismatch", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Action: "created"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "deleted", + "sender": map[string]interface{}{"login": "user1"}, + }, + want: false, + }, + { + name: "bodyContains filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Action: "created", BodyContains: "/fix"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "user1"}, + "comment": map[string]interface{}{"body": "please /fix this bug"}, + }, + want: true, + }, + { + name: "bodyContains filter no match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Action: "created", BodyContains: "/fix"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "user1"}, + "comment": map[string]interface{}{"body": "looks good to me"}, + }, + want: false, + }, + { + name: "label filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issues", Labels: []string{"bug"}}, + }, + }, + eventType: "issues", + payload: map[string]interface{}{ + "action": "opened", + "sender": map[string]interface{}{"login": "user1"}, + "issue": map[string]interface{}{ + "number": 1, + "state": "open", + "labels": []interface{}{ + map[string]interface{}{"name": "bug"}, + map[string]interface{}{"name": "priority/high"}, + }, + }, + }, + want: true, + }, + { + name: "label filter no match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issues", Labels: []string{"bug"}}, + }, + }, + eventType: "issues", + payload: map[string]interface{}{ + "action": "opened", + "sender": map[string]interface{}{"login": "user1"}, + "issue": map[string]interface{}{ + "number": 1, + "state": "open", + "labels": []interface{}{ + map[string]interface{}{"name": "feature"}, + }, + }, + }, + want: false, + }, + { + name: "author filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Author: "admin"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "admin"}, + }, + want: true, + }, + { + name: "author filter case insensitive", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", Author: "Admin"}, + }, + }, + eventType: "issue_comment", + payload: map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "admin"}, + }, + want: true, + }, + { + name: "push event with branch filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"push"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "push", Branch: "main"}, + }, + }, + eventType: "push", + payload: map[string]interface{}{ + "ref": "refs/heads/main", + "sender": map[string]interface{}{"login": "user1"}, + }, + want: true, + }, + { + name: "push event with branch glob match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"push"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "push", Branch: "release-*"}, + }, + }, + eventType: "push", + payload: map[string]interface{}{ + "ref": "refs/heads/release-1.0", + "sender": map[string]interface{}{"login": "user1"}, + }, + want: true, + }, + { + name: "push event with branch filter no match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"push"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "push", Branch: "main"}, + }, + }, + eventType: "push", + payload: map[string]interface{}{ + "ref": "refs/heads/feature-branch", + "sender": map[string]interface{}{"login": "user1"}, + }, + want: false, + }, + { + name: "draft filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"pull_request"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "pull_request", Draft: boolPtr(false)}, + }, + }, + eventType: "pull_request", + payload: map[string]interface{}{ + "action": "opened", + "sender": map[string]interface{}{"login": "user1"}, + "pull_request": map[string]interface{}{"number": 1, "state": "open", "draft": false}, + }, + want: true, + }, + { + name: "state filter match", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issues", State: "open"}, + }, + }, + eventType: "issues", + payload: map[string]interface{}{ + "action": "opened", + "sender": map[string]interface{}{"login": "user1"}, + "issue": map[string]interface{}{"number": 1, "state": "open"}, + }, + want: true, + }, + { + name: "OR semantics - second filter matches", + cfg: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment", "push"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", BodyContains: "/deploy"}, + {Event: "push", Branch: "main"}, + }, + }, + eventType: "push", + payload: map[string]interface{}{ + "ref": "refs/heads/main", + "sender": map[string]interface{}{"login": "user1"}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payloadBytes, _ := json.Marshal(tt.payload) + got := MatchesGitHubEvent(tt.cfg, tt.eventType, payloadBytes) + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractGitHubWorkItem(t *testing.T) { + payload := map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "testuser"}, + "issue": map[string]interface{}{ + "number": 42, + "title": "Fix the bug", + "body": "There is a bug", + "state": "open", + "html_url": "https://github.com/org/repo/issues/42", + "labels": []interface{}{map[string]interface{}{"name": "bug"}}, + }, + "comment": map[string]interface{}{ + "body": "/fix please address this", + }, + } + + payloadBytes, _ := json.Marshal(payload) + data := ExtractGitHubWorkItem("issue_comment", payloadBytes) + + if data == nil { + t.Fatal("ExtractGitHubWorkItem returned nil") + } + + if data["Event"] != "issue_comment" { + t.Errorf("Event = %v, want issue_comment", data["Event"]) + } + if data["Action"] != "created" { + t.Errorf("Action = %v, want created", data["Action"]) + } + if data["Sender"] != "testuser" { + t.Errorf("Sender = %v, want testuser", data["Sender"]) + } + if data["Number"] != 42 { + t.Errorf("Number = %v, want 42", data["Number"]) + } + // Body should be the comment body (overrides issue body) + if data["Body"] != "/fix please address this" { + t.Errorf("Body = %v, want /fix please address this", data["Body"]) + } + if data["Kind"] != "Issue" { + t.Errorf("Kind = %v, want Issue", data["Kind"]) + } + + // Check Payload is accessible + if data["Payload"] == nil { + t.Error("Payload should not be nil") + } +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go new file mode 100644 index 00000000..4ea93b5d --- /dev/null +++ b/internal/webhook/handler.go @@ -0,0 +1,287 @@ +package webhook + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/taskbuilder" +) + +// Handler processes incoming webhook requests and creates Tasks for matching TaskSpawners. +type Handler struct { + Client client.Client + Log logr.Logger + Source string // "github" or "linear" + Secret []byte +} + +// ServeHTTP handles webhook POST requests. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + h.Log.Error(err, "Reading request body") + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate signature + if err := h.validateSignature(body, r.Header); err != nil { + h.Log.Error(err, "Signature validation failed") + http.Error(w, "Signature validation failed", http.StatusUnauthorized) + return + } + + ctx := r.Context() + + // List all TaskSpawners + var spawnerList kelosv1alpha1.TaskSpawnerList + if err := h.Client.List(ctx, &spawnerList); err != nil { + h.Log.Error(err, "Listing TaskSpawners") + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Extract delivery ID for idempotency + deliveryID := h.extractDeliveryID(r.Header) + + var matched int + var created int + var atCapacity bool + + for i := range spawnerList.Items { + ts := &spawnerList.Items[i] + + // Skip suspended spawners + if ts.Spec.Suspend != nil && *ts.Spec.Suspend { + continue + } + + // Check if this spawner matches the incoming event + if !h.matchesSpawner(ts, r.Header, body) { + continue + } + matched++ + + // Idempotency: check if a Task with this delivery ID already exists + if deliveryID != "" { + existingTasks := &kelosv1alpha1.TaskList{} + if err := h.Client.List(ctx, existingTasks, + client.InNamespace(ts.Namespace), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + ); err == nil { + duplicate := false + for _, t := range existingTasks.Items { + if t.Annotations["kelos.dev/webhook-delivery"] == deliveryID { + duplicate = true + break + } + } + if duplicate { + h.Log.Info("Duplicate delivery, skipping", "spawner", ts.Name, "deliveryID", deliveryID) + continue + } + } + } + + // Check maxConcurrency + if ts.Spec.MaxConcurrency != nil && *ts.Spec.MaxConcurrency > 0 { + activeTasks, err := h.countActiveTasks(ctx, ts) + if err != nil { + h.Log.Error(err, "Counting active tasks", "spawner", ts.Name) + continue + } + if activeTasks >= int(*ts.Spec.MaxConcurrency) { + h.Log.Info("Max concurrency reached", "spawner", ts.Name, "active", activeTasks, "max", *ts.Spec.MaxConcurrency) + atCapacity = true + continue + } + } + + // Check maxTotalTasks + if ts.Spec.MaxTotalTasks != nil && *ts.Spec.MaxTotalTasks > 0 { + if ts.Status.TotalTasksCreated >= int(*ts.Spec.MaxTotalTasks) { + h.Log.Info("Task budget exhausted", "spawner", ts.Name) + continue + } + } + + // Create the Task + if err := h.createTask(ctx, ts, r.Header, body, deliveryID); err != nil { + h.Log.Error(err, "Creating task", "spawner", ts.Name) + continue + } + created++ + } + + h.Log.Info("Webhook processed", "source", h.Source, "matched", matched, "created", created) + + if atCapacity && created == 0 && matched > 0 { + w.Header().Set("Retry-After", "30") + http.Error(w, "All matching spawners at max concurrency", http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"matched":%d,"created":%d}`, matched, created) +} + +func (h *Handler) validateSignature(body []byte, headers http.Header) error { + if len(h.Secret) == 0 { + return nil + } + + switch h.Source { + case "github": + sig := headers.Get("X-Hub-Signature-256") + if sig == "" { + return fmt.Errorf("missing X-Hub-Signature-256 header") + } + return ValidateGitHubSignature(body, sig, h.Secret) + case "linear": + sig := headers.Get("Linear-Signature") + if sig == "" { + return fmt.Errorf("missing Linear-Signature header") + } + return ValidateLinearSignature(body, sig, h.Secret) + default: + return fmt.Errorf("unknown source type: %s", h.Source) + } +} + +func (h *Handler) extractDeliveryID(headers http.Header) string { + switch h.Source { + case "github": + return headers.Get("X-GitHub-Delivery") + case "linear": + return headers.Get("Linear-Delivery") + default: + return headers.Get("X-Request-ID") + } +} + +func (h *Handler) matchesSpawner(ts *kelosv1alpha1.TaskSpawner, headers http.Header, body []byte) bool { + switch h.Source { + case "github": + if ts.Spec.When.GitHubWebhook == nil { + return false + } + eventType := headers.Get("X-GitHub-Event") + return MatchesGitHubEvent(ts.Spec.When.GitHubWebhook, eventType, body) + case "linear": + if ts.Spec.When.LinearWebhook == nil { + return false + } + return MatchesLinearEvent(ts.Spec.When.LinearWebhook, body) + default: + return false + } +} + +func (h *Handler) countActiveTasks(ctx context.Context, ts *kelosv1alpha1.TaskSpawner) (int, error) { + var taskList kelosv1alpha1.TaskList + if err := h.Client.List(ctx, &taskList, + client.InNamespace(ts.Namespace), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + ); err != nil { + return 0, err + } + + active := 0 + for _, t := range taskList.Items { + if t.Status.Phase != kelosv1alpha1.TaskPhaseSucceeded && t.Status.Phase != kelosv1alpha1.TaskPhaseFailed { + active++ + } + } + return active, nil +} + +func (h *Handler) createTask(ctx context.Context, ts *kelosv1alpha1.TaskSpawner, headers http.Header, body []byte, deliveryID string) error { + // Extract work item data for template rendering + var templateVars map[string]interface{} + var eventType string + + switch h.Source { + case "github": + eventType = headers.Get("X-GitHub-Event") + templateVars = ExtractGitHubWorkItem(eventType, body) + case "linear": + eventType = "linear_webhook" // Linear doesn't have event type headers + templateVars = ExtractLinearWorkItem(body) + } + + if templateVars == nil { + return fmt.Errorf("failed to extract work item data from payload") + } + + // Generate a unique ID for this webhook event + id := sanitizeTaskName(deliveryID) + if id == "" { + id = fmt.Sprintf("%d", metav1.Now().UnixNano()) + } + + // Use TaskBuilder to create the task + builder := taskbuilder.NewTaskBuilder(ts) + task, err := builder.BuildTaskFromWebhook(id, templateVars, deliveryID, h.Source, eventType) + if err != nil { + return fmt.Errorf("building task: %w", err) + } + + return h.Client.Create(ctx, task) +} + +// sanitizeTaskName converts a delivery ID into a valid Kubernetes name suffix. +// Kubernetes names must be lowercase alphanumeric, '-', or '.', max 253 chars. +func sanitizeTaskName(id string) string { + if id == "" { + return "" + } + id = strings.ToLower(id) + // Keep only alphanumeric and hyphens + var b strings.Builder + for _, c := range id { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + b.WriteRune(c) + } + } + result := b.String() + // Trim to keep total task name under 253 chars (spawner name + "-" + this) + if len(result) > 36 { + result = result[:36] + } + return strings.TrimRight(result, "-") +} + +// HealthHandler returns a simple health check handler. +func HealthHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) +} + +// ReadyHandler returns a readiness check handler that verifies the client can reach the API server. +func ReadyHandler(cl client.Client) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var list kelosv1alpha1.TaskSpawnerList + if err := cl.List(r.Context(), &list, client.Limit(1)); err != nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) +} diff --git a/internal/webhook/linear_filter.go b/internal/webhook/linear_filter.go new file mode 100644 index 00000000..ecdcfd5e --- /dev/null +++ b/internal/webhook/linear_filter.go @@ -0,0 +1,162 @@ +package webhook + +import ( + "encoding/json" + "strings" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// linearWebhookPayload is a minimal representation of a Linear webhook payload. +type linearWebhookPayload struct { + Action string `json:"action"` + Type string `json:"type"` + Data struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + State *struct { + Name string `json:"name"` + } `json:"state"` + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + } `json:"data"` +} + +// MatchesLinearEvent checks whether a Linear webhook event matches a TaskSpawner's +// LinearWebhook configuration. +func MatchesLinearEvent(cfg *kelosv1alpha1.LinearWebhook, payload []byte) bool { + var p linearWebhookPayload + if err := json.Unmarshal(payload, &p); err != nil { + return false + } + + // Check if the resource type is in the configured types list + typeMatched := false + for _, t := range cfg.Types { + if strings.EqualFold(t, p.Type) { + typeMatched = true + break + } + } + if !typeMatched { + return false + } + + // If no filters, all matching types trigger + if len(cfg.Filters) == 0 { + return true + } + + // OR semantics: any matching filter triggers + for _, f := range cfg.Filters { + if matchesLinearFilter(&f, &p) { + return true + } + } + + return false +} + +func matchesLinearFilter(f *kelosv1alpha1.LinearWebhookFilter, p *linearWebhookPayload) bool { + if !strings.EqualFold(f.Type, p.Type) { + return false + } + + if f.Action != "" && !strings.EqualFold(f.Action, p.Action) { + return false + } + + if len(f.States) > 0 { + if p.Data.State == nil { + return false + } + stateMatched := false + for _, s := range f.States { + if strings.EqualFold(s, p.Data.State.Name) { + stateMatched = true + break + } + } + if !stateMatched { + return false + } + } + + if len(f.Labels) > 0 { + labels := extractLinearLabels(p) + if !containsAllLabels(labels, f.Labels) { + return false + } + } + + if len(f.ExcludeLabels) > 0 { + labels := extractLinearLabels(p) + for _, excl := range f.ExcludeLabels { + for _, l := range labels { + if strings.EqualFold(l, excl) { + return false + } + } + } + } + + return true +} + +func extractLinearLabels(p *linearWebhookPayload) []string { + labels := make([]string, len(p.Data.Labels.Nodes)) + for i, l := range p.Data.Labels.Nodes { + labels[i] = l.Name + } + return labels +} + +// ExtractLinearWorkItem extracts template variables from a Linear webhook payload. +func ExtractLinearWorkItem(payload []byte) map[string]interface{} { + var p linearWebhookPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil + } + + data := map[string]interface{}{ + "Event": p.Type, + "Action": p.Action, + "Title": p.Data.Title, + "Body": p.Data.Description, + "URL": p.Data.URL, + "ID": p.Data.ID, + "Kind": p.Type, + } + + if p.Data.State != nil { + data["State"] = p.Data.State.Name + } + + data["Labels"] = extractLinearLabels(&p) + + // Parse full payload for {{.Payload.*}} access + var fullPayload map[string]interface{} + json.Unmarshal(payload, &fullPayload) + data["Payload"] = fullPayload + + return data +} + +// containsAllLabels checks if the issue has all the required labels. +func containsAllLabels(have []string, want []string) bool { + set := make(map[string]struct{}, len(have)) + for _, l := range have { + set[l] = struct{}{} + } + for _, w := range want { + if _, ok := set[w]; !ok { + return false + } + } + return true +} diff --git a/internal/webhook/linear_filter_test.go b/internal/webhook/linear_filter_test.go new file mode 100644 index 00000000..3fb46866 --- /dev/null +++ b/internal/webhook/linear_filter_test.go @@ -0,0 +1,227 @@ +package webhook + +import ( + "encoding/json" + "testing" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestMatchesLinearEvent(t *testing.T) { + tests := []struct { + name string + cfg *kelosv1alpha1.LinearWebhook + payload interface{} + want bool + }{ + { + name: "type not in list", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + }, + payload: map[string]interface{}{ + "type": "Comment", + "action": "create", + }, + want: false, + }, + { + name: "type matches, no filters", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + }, + want: true, + }, + { + name: "type case insensitive", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + }, + payload: map[string]interface{}{ + "type": "issue", + "action": "create", + }, + want: true, + }, + { + name: "action filter match", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", Action: "create"}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{"id": "123"}, + }, + want: true, + }, + { + name: "action filter no match", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", Action: "create"}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "update", + }, + want: false, + }, + { + name: "state filter match", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", States: []string{"Todo"}}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{ + "state": map[string]interface{}{"name": "Todo"}, + }, + }, + want: true, + }, + { + name: "state filter no match", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", States: []string{"Todo"}}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{ + "state": map[string]interface{}{"name": "Done"}, + }, + }, + want: false, + }, + { + name: "label filter match", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", Labels: []string{"bug"}}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{ + "labels": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{"name": "bug"}, + map[string]interface{}{"name": "priority"}, + }, + }, + }, + }, + want: true, + }, + { + name: "excludeLabels filter", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", ExcludeLabels: []string{"wontfix"}}, + }, + }, + payload: map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{ + "labels": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{"name": "wontfix"}, + }, + }, + }, + }, + want: false, + }, + { + name: "OR semantics - second filter matches", + cfg: &kelosv1alpha1.LinearWebhook{ + Types: []string{"Issue", "Comment"}, + Filters: []kelosv1alpha1.LinearWebhookFilter{ + {Type: "Issue", Action: "create"}, + {Type: "Comment", Action: "create"}, + }, + }, + payload: map[string]interface{}{ + "type": "Comment", + "action": "create", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payloadBytes, _ := json.Marshal(tt.payload) + got := MatchesLinearEvent(tt.cfg, payloadBytes) + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractLinearWorkItem(t *testing.T) { + payload := map[string]interface{}{ + "type": "Issue", + "action": "create", + "data": map[string]interface{}{ + "id": "abc-123", + "title": "Fix login page", + "description": "The login page is broken", + "url": "https://linear.app/team/issue/ABC-123", + "state": map[string]interface{}{"name": "Todo"}, + "labels": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{"name": "bug"}, + }, + }, + }, + } + + payloadBytes, _ := json.Marshal(payload) + data := ExtractLinearWorkItem(payloadBytes) + + if data == nil { + t.Fatal("ExtractLinearWorkItem returned nil") + } + + if data["Event"] != "Issue" { + t.Errorf("Event = %v, want Issue", data["Event"]) + } + if data["Action"] != "create" { + t.Errorf("Action = %v, want create", data["Action"]) + } + if data["Title"] != "Fix login page" { + t.Errorf("Title = %v, want Fix login page", data["Title"]) + } + if data["ID"] != "abc-123" { + t.Errorf("ID = %v, want abc-123", data["ID"]) + } + if data["State"] != "Todo" { + t.Errorf("State = %v, want Todo", data["State"]) + } + if data["Payload"] == nil { + t.Error("Payload should not be nil") + } +} diff --git a/internal/webhook/signature.go b/internal/webhook/signature.go new file mode 100644 index 00000000..d5534287 --- /dev/null +++ b/internal/webhook/signature.go @@ -0,0 +1,60 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// ValidateGitHubSignature validates a GitHub webhook HMAC-SHA256 signature. +// The signature header value should be in the form "sha256=". +func ValidateGitHubSignature(payload []byte, signatureHeader string, secret []byte) error { + if len(secret) == 0 { + return fmt.Errorf("webhook secret is empty") + } + + sig := strings.TrimPrefix(signatureHeader, "sha256=") + if sig == signatureHeader { + return fmt.Errorf("signature header missing sha256= prefix") + } + + expected, err := hex.DecodeString(sig) + if err != nil { + return fmt.Errorf("decoding signature hex: %w", err) + } + + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + computed := mac.Sum(nil) + + if !hmac.Equal(computed, expected) { + return fmt.Errorf("signature mismatch") + } + + return nil +} + +// ValidateLinearSignature validates a Linear webhook signature. +// Linear uses a raw HMAC-SHA256 hex digest without a prefix. +func ValidateLinearSignature(payload []byte, signatureHeader string, secret []byte) error { + if len(secret) == 0 { + return fmt.Errorf("webhook secret is empty") + } + + expected, err := hex.DecodeString(signatureHeader) + if err != nil { + return fmt.Errorf("decoding signature hex: %w", err) + } + + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + computed := mac.Sum(nil) + + if !hmac.Equal(computed, expected) { + return fmt.Errorf("signature mismatch") + } + + return nil +} diff --git a/internal/webhook/signature_test.go b/internal/webhook/signature_test.go new file mode 100644 index 00000000..c1e5d960 --- /dev/null +++ b/internal/webhook/signature_test.go @@ -0,0 +1,106 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func computeHMACSHA256(payload, secret []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestValidateGitHubSignature(t *testing.T) { + secret := []byte("test-secret") + payload := []byte(`{"action":"opened"}`) + validSig := "sha256=" + computeHMACSHA256(payload, secret) + + tests := []struct { + name string + sig string + secret []byte + wantErr bool + }{ + { + name: "valid signature", + sig: validSig, + secret: secret, + }, + { + name: "wrong signature", + sig: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + secret: secret, + wantErr: true, + }, + { + name: "missing prefix", + sig: computeHMACSHA256(payload, secret), + secret: secret, + wantErr: true, + }, + { + name: "empty secret", + sig: validSig, + secret: []byte{}, + wantErr: true, + }, + { + name: "wrong secret", + sig: validSig, + secret: []byte("wrong-secret"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGitHubSignature(payload, tt.sig, tt.secret) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateGitHubSignature() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateLinearSignature(t *testing.T) { + secret := []byte("linear-secret") + payload := []byte(`{"action":"create","type":"Issue"}`) + validSig := computeHMACSHA256(payload, secret) + + tests := []struct { + name string + sig string + secret []byte + wantErr bool + }{ + { + name: "valid signature", + sig: validSig, + secret: secret, + }, + { + name: "wrong signature", + sig: "0000000000000000000000000000000000000000000000000000000000000000", + secret: secret, + wantErr: true, + }, + { + name: "empty secret", + sig: validSig, + secret: []byte{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateLinearSignature(payload, tt.sig, tt.secret) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateLinearSignature() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..e77d6765 --- /dev/null +++ b/plan.md @@ -0,0 +1,223 @@ +# Per-Type Webhook Servers — Implementation Plan + +## Architecture + +``` + Ingress (single host) + /webhook/github → kelos-webhook-server --source=github + /webhook/linear → kelos-webhook-server --source=linear + ↓ ↓ + Watches all TaskSpawners Watches all TaskSpawners + with spec.when.githubWebhook with spec.when.linearWebhook + ↓ ↓ + Matches event → filters Matches event → filters + ↓ ↓ + Creates Tasks Creates Tasks +``` + +Single binary, single image, `--source` flag selects behavior. One Deployment per source type. + +### Design Decisions + +- **Per-type receivers** (not singleton): fault isolation, independent scaling, clean secret boundaries +- **maxConcurrency exceeded**: return 503 + `Retry-After` header (rely on GitHub/Linear retry) +- **Secrets**: deployment-level, one `WEBHOOK_SECRET` env var per receiver deployment +- **Template variables**: support full payload access via `{{.Payload.field.sub}}` +- **GitHub SDK**: Use `github.com/google/go-github/v66` for complete type safety and field coverage + +--- + +## API Design + +### TaskSpawner Types + +```go +// When defines trigger sources for task spawning. +type When struct { + GitHubIssues *GitHubIssues `json:"githubIssues,omitempty"` + Cron *Cron `json:"cron,omitempty"` + GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` +} + +// GitHubWebhook configures webhook-driven task spawning from GitHub events. +type GitHubWebhook struct { + // Events is the list of GitHub event types to listen for. + // e.g., "issue_comment", "pull_request_review", "push", "issues" + Events []string `json:"events"` + + // Filters refine which events trigger tasks. If multiple filters match + // the same event type, any match triggers a task (OR semantics). + // If empty, all events in the Events list trigger tasks. + Filters []GitHubWebhookFilter `json:"filters,omitempty"` +} + +type GitHubWebhookFilter struct { + // Event is the GitHub event type this filter applies to. + Event string `json:"event"` + // Action filters by webhook action (e.g., "created", "opened", "submitted"). + Action string `json:"action,omitempty"` + // BodyContains filters by substring match on the comment/review body. + BodyContains string `json:"bodyContains,omitempty"` + // Labels requires the issue/PR to have all of these labels. + Labels []string `json:"labels,omitempty"` + // State filters by issue/PR state ("open", "closed"). + State string `json:"state,omitempty"` + // Branch filters push events by branch name (exact match or glob). + Branch string `json:"branch,omitempty"` + // Draft filters PRs by draft status. nil = don't filter. + Draft *bool `json:"draft,omitempty"` + // Author filters by the event sender's username. + Author string `json:"author,omitempty"` +} + +// LinearWebhook configures webhook-driven task spawning from Linear events. +type LinearWebhook struct { + // Types is the list of Linear resource types to listen for. + // e.g., "Issue", "Comment", "Project" + Types []string `json:"types"` + + // Filters refine which events trigger tasks (OR semantics within same type). + Filters []LinearWebhookFilter `json:"filters,omitempty"` +} + +type LinearWebhookFilter struct { + // Type is the Linear resource type this filter applies to. + Type string `json:"type"` + // Action filters by webhook action ("create", "update", "remove"). + Action string `json:"action,omitempty"` + // States filters by Linear workflow state names (e.g., "Todo", "In Progress"). + States []string `json:"states,omitempty"` + // Labels requires the issue to have all of these labels. + Labels []string `json:"labels,omitempty"` + // ExcludeLabels excludes issues with any of these labels. + ExcludeLabels []string `json:"excludeLabels,omitempty"` +} +``` + +### Template Variables + +All existing template variables remain. New variables available for webhook-sourced tasks: + +``` +{{.Event}} — GitHub event type or Linear resource type +{{.Action}} — Webhook action (e.g., "created", "submitted") +{{.Sender}} — Username of the person who triggered the event +{{.Ref}} — Git ref for push events +{{.Payload.field.sub}} — Access any field in the raw webhook payload +``` + +--- + +## Implementation Components + +### Core Webhook Infrastructure + +#### API Types +- Updated `api/v1alpha1/taskspawner_types.go` with GitHubWebhook and LinearWebhook fields +- Added comprehensive filter types with validation tags +- Generated deepcopy methods and CRD manifests + +#### GitHub Filter Package with go-github SDK + +`internal/webhook/github_filter.go`: +- **Uses `github.com/google/go-github/v66` SDK** for complete GitHub webhook coverage +- `ParseGitHubWebhook(eventType string, payload []byte) (*GitHubEventData, error)` — parses using `github.ParseWebHook()` for type safety +- `MatchesGitHubEvent(spawner *GitHubWebhook, eventType string, payload []byte) bool` — evaluates filters against typed structs +- **Supports all GitHub event types** with full field access (issues, pull_requests, push, reviews, etc.) +- OR semantics across filters for the same event type +- **Forward compatible** — new GitHub fields automatically available via SDK updates + +#### Linear Filter Package + +`internal/webhook/linear_filter.go`: +- `MatchesLinearEvent(spawner *LinearWebhook, payload []byte) bool` +- Manual JSON parsing for Linear webhooks (no official Linear Go SDK) + +#### Signature Validation + +`internal/webhook/signature.go`: +- `ValidateGitHubSignature(payload []byte, signature string, secret []byte) error` — HMAC-SHA256 with `sha256=` prefix parsing +- `ValidateLinearSignature(payload []byte, signature string, secret []byte) error` — Raw HMAC-SHA256 hex digest +- Uses standard crypto/hmac for reliable validation + +#### Task Creation Package +`internal/taskbuilder/builder.go`: +- Shared task creation logic usable by both the existing spawner and the webhook server +- Renders `promptTemplate` with webhook-specific template variables +- Stores event metadata as Task annotations for auditability + +#### Webhook Server Binary +`cmd/kelos-webhook-server/main.go`: +- HTTP server with controller-runtime manager +- Watches TaskSpawner CRs by source type +- Handles webhook POST requests with signature validation, filtering, and Task creation + +#### Helm Chart Templates +- Per-source-type Deployment + Service + RBAC +- Single Ingress with path-based routing +- Configurable via values.yaml + +#### Tests +- Unit tests for GitHub filter evaluation using go-github parsed structs +- Unit tests for Linear filter evaluation with manual JSON parsing +- Unit tests for signature validation (GitHub HMAC-SHA256, Linear raw hex) +- Integration tests: webhook POST → Task creation + +#### Build Infrastructure +- Dockerfile + Makefile + CI updates + +--- + +## Key Benefits of go-github SDK Integration + +### 1. **Complete Field Coverage** +Instead of manually defining JSON structs for a subset of fields, we get every field GitHub sends: +```go +// Before: Limited manual struct +type githubWebhookPayload struct { + Action string `json:"action"` + // ... only ~10 fields +} + +// After: Full go-github event structs +event := data.RawEvent.(*github.PullRequestEvent) +// Access to ALL fields: event.PullRequest.Assignees, .RequestedReviewers, .Milestone, etc. +``` + +### 2. **Forward Compatibility** +When GitHub adds new webhook fields: +- **Before**: Manual updates required to our JSON structs +- **After**: Automatic via `go mod update github.com/google/go-github/v66` + +### 3. **Type Safety** +- **Before**: `payload["pull_request"]["draft"]` (runtime errors possible) +- **After**: `event.PullRequest.Draft` (compile-time type checking) + +### 4. **Rich Template Access** +Users can access any field via `{{.Payload.*}}` while still having backward-compatible variables like `{{.Number}}`, `{{.Title}}`, etc. + +--- + +## Implementation Files + +``` +go.mod — github.com/google/go-github/v66 dependency +go.sum — updated dependencies +api/v1alpha1/taskspawner_types.go — GitHubWebhook, LinearWebhook types +api/v1alpha1/zz_generated.deepcopy.go — generated deepcopy methods +internal/webhook/signature.go — HMAC signature validation +internal/webhook/github_filter.go — GitHub event parsing & filtering +internal/webhook/linear_filter.go — Linear event parsing & filtering +internal/taskbuilder/builder.go — shared task creation logic +internal/webhook/handler.go — HTTP webhook handler +cmd/kelos-webhook-server/main.go — webhook server binary +cmd/kelos-webhook-server/Dockerfile — container image +internal/manifests/charts/kelos/templates/webhook-server.yaml — Helm deployment +internal/manifests/charts/kelos/templates/webhook-ingress.yaml — Helm ingress +internal/manifests/charts/kelos/values.yaml — Helm configuration +examples/10-taskspawner-github-webhook/ — GitHub webhook example +examples/11-taskspawner-linear-webhook/ — Linear webhook example +test/integration/webhook_handler_test.go — integration tests +internal/webhook/*_test.go — unit tests +``` \ No newline at end of file diff --git a/test/integration/webhook_handler_test.go b/test/integration/webhook_handler_test.go new file mode 100644 index 00000000..5117cfd4 --- /dev/null +++ b/test/integration/webhook_handler_test.go @@ -0,0 +1,359 @@ +package integration + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/webhook" +) + +func signPayload(payload, secret []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +var _ = Describe("Webhook Handler", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("GitHub webhook triggers task creation", func() { + var ( + ns *corev1.Namespace + handler *webhook.Handler + secret = []byte("test-webhook-secret") + ) + + BeforeEach(func() { + ns = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("test-webhook-%d", rand.Intn(1000000)), + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + handler = &webhook.Handler{ + Client: k8sClient, + Log: logf.Log.WithName("test-webhook-handler"), + Source: "github", + Secret: secret, + } + }) + + It("Should create a Task when a matching webhook arrives", func() { + By("Creating a Workspace") + ws := &kelosv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ws", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WorkspaceSpec{ + Repo: "https://github.com/kelos-dev/kelos.git", + Ref: "main", + }, + } + Expect(k8sClient.Create(ctx, ws)).Should(Succeed()) + + By("Creating a TaskSpawner with githubWebhook") + ts := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "webhook-test-spawner", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + { + Event: "issue_comment", + Action: "created", + BodyContains: "/fix", + }, + }, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeNone, + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ + Name: "test-ws", + }, + PromptTemplate: `Fix request from {{.Sender}} on #{{.Number}}: {{.Body}}`, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Sending a matching webhook") + payload := map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "testuser"}, + "issue": map[string]interface{}{ + "number": 42, + "state": "open", + "title": "Bug report", + "body": "Something is broken", + "html_url": "https://github.com/org/repo/issues/42", + "labels": []interface{}{}, + }, + "comment": map[string]interface{}{ + "body": "/fix please address this", + }, + } + payloadBytes, err := json.Marshal(payload) + Expect(err).NotTo(HaveOccurred()) + + req := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req.Header.Set("X-GitHub-Event", "issue_comment") + req.Header.Set("X-GitHub-Delivery", "test-delivery-123") + req.Header.Set("X-Hub-Signature-256", signPayload(payloadBytes, secret)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusOK)) + + By("Verifying the Task was created") + Eventually(func() error { + var taskList kelosv1alpha1.TaskList + if err := k8sClient.List(ctx, &taskList, + client.InNamespace(ns.Name), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + ); err != nil { + return err + } + if len(taskList.Items) == 0 { + return Errorf("no tasks found") + } + return nil + }, timeout, interval).Should(Succeed()) + + var taskList kelosv1alpha1.TaskList + Expect(k8sClient.List(ctx, &taskList, + client.InNamespace(ns.Name), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + )).Should(Succeed()) + + task := taskList.Items[0] + Expect(task.Annotations["kelos.dev/webhook-source"]).To(Equal("github")) + Expect(task.Annotations["kelos.dev/webhook-delivery"]).To(Equal("test-delivery-123")) + Expect(task.Annotations["kelos.dev/webhook-event"]).To(Equal("issue_comment")) + Expect(task.Spec.Prompt).To(ContainSubstring("testuser")) + Expect(task.Spec.Prompt).To(ContainSubstring("/fix please address this")) + }) + + It("Should reject requests with invalid signatures", func() { + payload := []byte(`{"action":"created"}`) + + req := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "issue_comment") + req.Header.Set("X-Hub-Signature-256", "sha256=invalid") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("Should not create duplicate tasks for the same delivery ID", func() { + By("Creating a TaskSpawner") + ts := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "webhook-dedup-spawner", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"push"}, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeNone, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + payload := map[string]interface{}{ + "ref": "refs/heads/main", + "action": "", + "sender": map[string]interface{}{"login": "user"}, + } + payloadBytes, _ := json.Marshal(payload) + sig := signPayload(payloadBytes, secret) + + By("Sending first webhook") + req1 := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req1.Header.Set("X-GitHub-Event", "push") + req1.Header.Set("X-GitHub-Delivery", "dedup-delivery-456") + req1.Header.Set("X-Hub-Signature-256", sig) + + rr1 := httptest.NewRecorder() + handler.ServeHTTP(rr1, req1) + Expect(rr1.Code).To(Equal(http.StatusOK)) + + By("Sending duplicate webhook with same delivery ID") + req2 := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req2.Header.Set("X-GitHub-Event", "push") + req2.Header.Set("X-GitHub-Delivery", "dedup-delivery-456") + req2.Header.Set("X-Hub-Signature-256", sig) + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + Expect(rr2.Code).To(Equal(http.StatusOK)) + + By("Verifying only one task was created") + var taskList kelosv1alpha1.TaskList + Expect(k8sClient.List(ctx, &taskList, + client.InNamespace(ns.Name), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + )).Should(Succeed()) + Expect(taskList.Items).To(HaveLen(1)) + }) + + It("Should return 503 when at maxConcurrency", func() { + maxConc := int32(1) + ts := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "webhook-maxconc-spawner", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + MaxConcurrency: &maxConc, + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"pull_request_review"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "pull_request_review", Action: "submitted"}, + }, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeNone, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + payload := map[string]interface{}{ + "action": "submitted", + "sender": map[string]interface{}{"login": "user"}, + "review": map[string]interface{}{"body": "changes requested", "state": "changes_requested"}, + "pull_request": map[string]interface{}{ + "number": 99, + "state": "open", + "title": "Test PR", + }, + } + payloadBytes, _ := json.Marshal(payload) + sig := signPayload(payloadBytes, secret) + + By("Sending first webhook to fill capacity") + req1 := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req1.Header.Set("X-GitHub-Event", "pull_request_review") + req1.Header.Set("X-GitHub-Delivery", "maxconc-review-1") + req1.Header.Set("X-Hub-Signature-256", sig) + + rr1 := httptest.NewRecorder() + handler.ServeHTTP(rr1, req1) + Expect(rr1.Code).To(Equal(http.StatusOK)) + + By("Sending second webhook that should be rejected") + req2 := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req2.Header.Set("X-GitHub-Event", "pull_request_review") + req2.Header.Set("X-GitHub-Delivery", "maxconc-review-2") + req2.Header.Set("X-Hub-Signature-256", sig) + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + Expect(rr2.Code).To(Equal(http.StatusServiceUnavailable)) + Expect(rr2.Header().Get("Retry-After")).To(Equal("30")) + }) + + It("Should not create tasks when filters don't match", func() { + ts := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "webhook-nomatch-spawner", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issue_comment"}, + Filters: []kelosv1alpha1.GitHubWebhookFilter{ + {Event: "issue_comment", BodyContains: "/deploy"}, + }, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeNone, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + payload := map[string]interface{}{ + "action": "created", + "sender": map[string]interface{}{"login": "user"}, + "comment": map[string]interface{}{"body": "looks good"}, + } + payloadBytes, _ := json.Marshal(payload) + sig := signPayload(payloadBytes, secret) + + req := httptest.NewRequest(http.MethodPost, "/webhook/github", bytes.NewReader(payloadBytes)) + req.Header.Set("X-GitHub-Event", "issue_comment") + req.Header.Set("X-GitHub-Delivery", "nomatch-1") + req.Header.Set("X-Hub-Signature-256", sig) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + Expect(rr.Code).To(Equal(http.StatusOK)) + + var taskList kelosv1alpha1.TaskList + Expect(k8sClient.List(ctx, &taskList, + client.InNamespace(ns.Name), + client.MatchingLabels{"kelos.dev/taskspawner": ts.Name}, + )).Should(Succeed()) + Expect(taskList.Items).To(BeEmpty()) + }) + }) +}) + +func Errorf(format string, args ...interface{}) error { + return &errorf{msg: format} +} + +type errorf struct{ msg string } + +func (e *errorf) Error() string { return e.msg }