From ed6ebdd9ad774e483e8527f3834728def292e71a Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Mon, 30 Mar 2026 12:10:53 +0200 Subject: [PATCH 1/4] feat: add github and linear webhook events --- api/v1alpha1/taskspawner_types.go | 101 +++- api/v1alpha1/zz_generated.deepcopy.go | 119 ++++ cmd/kelos-webhook-server/Dockerfile | 26 + cmd/kelos-webhook-server/main.go | 140 +++++ .../10-taskspawner-github-webhook/README.md | 143 +++++ .../taskspawner.yaml | 132 +++++ .../11-taskspawner-linear-webhook/README.md | 214 +++++++ .../taskspawner.yaml | 172 ++++++ examples/helm-values-webhook.yaml | 34 ++ go.mod | 2 + go.sum | 6 + internal/cli/webhook_test.go | 104 ++++ internal/manifests/charts/kelos/README.md | 69 +++ .../kelos/templates/crds/taskspawner-crd.yaml | 126 +++- .../charts/kelos/templates/rbac.yaml | 44 ++ .../kelos/templates/serviceaccount.yaml | 6 + .../kelos/templates/webhook-ingress.yaml | 45 ++ .../kelos/templates/webhook-server.yaml | 201 +++++++ internal/manifests/charts/kelos/values.yaml | 16 + internal/manifests/install-crd.yaml | 126 +++- internal/taskbuilder/builder.go | 141 +++++ internal/webhook/github_filter.go | 330 +++++++++++ internal/webhook/github_filter_test.go | 462 +++++++++++++++ internal/webhook/handler.go | 389 +++++++++++++ internal/webhook/linear_filter.go | 218 +++++++ internal/webhook/linear_filter_test.go | 541 ++++++++++++++++++ internal/webhook/signature.go | 48 ++ internal/webhook/signature_test.go | 87 +++ 28 files changed, 4033 insertions(+), 9 deletions(-) create mode 100644 cmd/kelos-webhook-server/Dockerfile create mode 100644 cmd/kelos-webhook-server/main.go create mode 100644 examples/10-taskspawner-github-webhook/README.md create mode 100644 examples/10-taskspawner-github-webhook/taskspawner.yaml create mode 100644 examples/11-taskspawner-linear-webhook/README.md create mode 100644 examples/11-taskspawner-linear-webhook/taskspawner.yaml create mode 100644 examples/helm-values-webhook.yaml create mode 100644 internal/cli/webhook_test.go create mode 100644 internal/manifests/charts/kelos/templates/webhook-ingress.yaml create mode 100644 internal/manifests/charts/kelos/templates/webhook-server.yaml create mode 100644 internal/taskbuilder/builder.go create mode 100644 internal/webhook/github_filter.go create mode 100644 internal/webhook/github_filter_test.go create mode 100644 internal/webhook/handler.go create mode 100644 internal/webhook/linear_filter.go create mode 100644 internal/webhook/linear_filter_test.go create mode 100644 internal/webhook/signature.go create mode 100644 internal/webhook/signature_test.go diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index 7dc07719..2196237c 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -36,6 +36,14 @@ type When struct { // Jira discovers issues from a Jira project. // +optional Jira *Jira `json:"jira,omitempty"` + + // GitHubWebhook triggers task spawning on GitHub webhook events. + // +optional + GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + + // LinearWebhook triggers task spawning on Linear webhook events. + // +optional + LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` } // Cron triggers task spawning on a cron schedule. @@ -295,6 +303,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" + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + 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. + // +optional + Filters []GitHubWebhookFilter `json:"filters,omitempty"` +} + +// GitHubWebhookFilter defines filtering criteria for GitHub webhook events. +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/review body. + // +optional + BodyContains string `json:"bodyContains,omitempty"` + + // Labels requires the issue/PR to have all of these labels. + // +optional + Labels []string `json:"labels,omitempty"` + + // State filters by issue/PR state ("open", "closed"). + // +optional + State string `json:"state,omitempty"` + + // Branch filters push events by branch name (exact match or glob). + // +optional + Branch string `json:"branch,omitempty"` + + // Draft filters PRs by draft status. nil = don't filter. + // +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 within same type). + // If empty, all events in the Types list trigger tasks. + // +optional + Filters []LinearWebhookFilter `json:"filters,omitempty"` +} + +// LinearWebhookFilter defines filtering criteria for Linear webhook events. +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 @@ -355,6 +450,8 @@ type TaskTemplate struct { // Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} // GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} // GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + // GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access) + // Linear webhook sources: {{.Type}}, {{.Action}}, {{.Payload}} (full payload access) // Cron sources: {{.Time}}, {{.Schedule}} // +optional Branch string `json:"branch,omitempty"` @@ -363,6 +460,8 @@ type TaskTemplate struct { // Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} // GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} // GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + // GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access) + // Linear webhook sources: {{.Type}}, {{.Action}}, {{.Payload}} (full payload access) // Cron sources: {{.Time}}, {{.Schedule}} // +optional PromptTemplate string `json:"promptTemplate,omitempty"` @@ -396,7 +495,7 @@ type TaskTemplate struct { } // TaskSpawnerSpec defines the desired state of TaskSpawner. -// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using githubIssues or githubPullRequests source" +// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests) || has(self.when.githubWebhook)) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using githubIssues, githubPullRequests, or githubWebhook source" type TaskSpawnerSpec struct { // When defines the conditions that trigger task spawning. // +kubebuilder:validation:Required 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..c2a6719f --- /dev/null +++ b/cmd/kelos-webhook-server/Dockerfile @@ -0,0 +1,26 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /workspace + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o webhook-server ./cmd/kelos-webhook-server + +# Runtime stage +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +# Copy the binary +COPY --from=builder /workspace/webhook-server . + +USER 65532:65532 + +ENTRYPOINT ["/webhook-server"] \ No newline at end of file diff --git a/cmd/kelos-webhook-server/main.go b/cmd/kelos-webhook-server/main.go new file mode 100644 index 00000000..3c13fe21 --- /dev/null +++ b/cmd/kelos-webhook-server/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "strings" + + "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/healthz" + "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() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) +} + +func main() { + var ( + source string + metricsAddr string + probeAddr string + webhookAddr string + enableLeaderElection bool + ) + + flag.StringVar(&source, "source", "", "Webhook source type (github or linear)") + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar(&webhookAddr, "webhook-bind-address", ":8443", "The address the webhook endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + + opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) + flag.Parse() + + if err := applyVerbosity(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(opts))) + + // Validate source parameter + source = strings.ToLower(strings.TrimSpace(source)) + var webhookSource webhook.WebhookSource + switch source { + case "github": + webhookSource = webhook.GitHubSource + case "linear": + webhookSource = webhook.LinearSource + default: + setupLog.Error(fmt.Errorf("invalid source: %s", source), + "Source must be 'github' or 'linear'") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: fmt.Sprintf("kelos-webhook-%s", source), + }) + if err != nil { + setupLog.Error(err, "Unable to start manager") + os.Exit(1) + } + + // Create webhook handler + handler, err := webhook.NewWebhookHandler( + mgr.GetClient(), + webhookSource, + ctrl.Log.WithName("webhook").WithValues("source", source), + ) + if err != nil { + setupLog.Error(err, "Unable to create webhook handler") + os.Exit(1) + } + + // Set up HTTP server for webhooks + mux := http.NewServeMux() + mux.Handle("/", handler) + + webhookServer := &http.Server{ + Addr: webhookAddr, + Handler: mux, + } + + // Start webhook server in goroutine + go func() { + setupLog.Info("Starting webhook server", "addr", webhookAddr, "source", source) + if err := webhookServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + setupLog.Error(err, "Webhook server failed") + os.Exit(1) + } + }() + + // Add health checks + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "Unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "Unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("Starting manager") + ctx := ctrl.SetupSignalHandler() + + // Shutdown webhook server gracefully when context is cancelled + go func() { + <-ctx.Done() + setupLog.Info("Shutting down webhook server") + if err := webhookServer.Shutdown(context.Background()); err != nil { + setupLog.Error(err, "Error shutting down webhook server") + } + }() + + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "Problem running manager") + os.Exit(1) + } +} diff --git a/examples/10-taskspawner-github-webhook/README.md b/examples/10-taskspawner-github-webhook/README.md new file mode 100644 index 00000000..202f5ccc --- /dev/null +++ b/examples/10-taskspawner-github-webhook/README.md @@ -0,0 +1,143 @@ +# GitHub Webhook TaskSpawner Example + +This example demonstrates how to configure a TaskSpawner to respond to GitHub webhook events. + +## Overview + +The GitHub webhook TaskSpawner triggers task creation based on GitHub repository events like: +- Issues being opened, closed, or commented on +- Pull requests being opened, reviewed, or merged +- Code being pushed to specific branches +- And many other GitHub events + +## Prerequisites + +1. **Webhook Server**: Deploy the kelos-webhook-server with GitHub source configuration +2. **GitHub Webhook**: Configure your GitHub repository to send webhooks to your Kelos instance +3. **Secret**: Create a Kubernetes secret containing the webhook signing secret + +## Setup + +### 1. Create Webhook Secret + +```bash +kubectl create secret generic github-webhook-secret \ + --from-literal=WEBHOOK_SECRET=your-github-webhook-secret +``` + +### 2. Configure GitHub Repository Webhook + +In your GitHub repository settings: +1. Go to Settings → Webhooks → Add webhook +2. Set Payload URL to: `https://your-kelos-instance.com/webhook/github` +3. Set Content type to: `application/json` +4. Set Secret to the same value as in your Kubernetes secret +5. Select events you want to receive (or "Send me everything") + +### 3. Deploy TaskSpawner + +Apply the TaskSpawner configuration: + +```bash +kubectl apply -f taskspawner.yaml +``` + +## Configuration Details + +The example TaskSpawner demonstrates several filtering patterns: + +- **Event Types**: Only responds to `issues`, `pull_request`, and `issue_comment` events +- **Action Filtering**: Responds to specific actions like "opened", "created", etc. +- **Label Requirements**: Can require specific labels to be present +- **Author Filtering**: Can filter by the user who triggered the event +- **Draft Filtering**: Can exclude or include draft pull requests + +## Template Variables + +GitHub webhook events provide rich template variables for task creation: + +### Standard Variables +- `{{.Event}}` - GitHub event type (e.g., "issues", "pull_request") +- `{{.Action}}` - Webhook action (e.g., "opened", "created", "submitted") +- `{{.Sender}}` - Username of person who triggered the event +- `{{.ID}}` - Issue/PR number as string +- `{{.Title}}` - Issue/PR title +- `{{.Number}}` - Issue/PR number as integer +- `{{.Body}}` - Issue/PR body text +- `{{.URL}}` - Issue/PR HTML URL +- `{{.Branch}}` - PR source branch or push branch +- `{{.Ref}}` - Git ref for push events + +### Raw Payload Access +- `{{.Payload.*}}` - Access any field from the GitHub webhook payload + +Example template usage: +```yaml +promptTemplate: | + A new {{.Event}} event occurred in the repository. + + Event: {{.Event}} + Action: {{.Action}} + Triggered by: {{.Sender}} + + {{if .Title}}Title: {{.Title}}{{end}} + {{if .URL}}URL: {{.URL}}{{end}} + + Please investigate and take appropriate action. + +branch: "webhook-{{.Event}}-{{.ID}}" +``` + +## Webhook Security + +The webhook server validates GitHub signatures using HMAC-SHA256: +- GitHub sends signatures in `X-Hub-Signature-256` header with `sha256=` prefix +- The server validates against the secret stored in `WEBHOOK_SECRET` env var +- Invalid signatures result in HTTP 401 responses + +## Scaling and Reliability + +### Concurrency Control +- Set `maxConcurrency` to limit parallel tasks from webhook events +- When exceeded, returns HTTP 503 with `Retry-After` header +- GitHub will automatically retry failed webhook deliveries + +### Idempotency +- Webhook deliveries are tracked by `X-GitHub-Delivery` header +- Duplicate deliveries (e.g., retries) are ignored +- Delivery cache entries expire after 24 hours + +### Fault Isolation +- Per-source webhook servers provide fault isolation +- GitHub webhook failures don't affect Linear or other sources +- Each source can be scaled independently + +## Troubleshooting + +### Common Issues + +1. **Tasks not being created** + - Check webhook server logs for signature validation errors + - Verify GitHub webhook is configured with correct URL and secret + - Check TaskSpawner event type and filter configuration + +2. **Signature validation failures** + - Ensure WEBHOOK_SECRET matches GitHub webhook secret exactly + - Check for trailing newlines or encoding issues in secret + +3. **Max concurrency errors** + - Increase maxConcurrency limit or reduce webhook frequency + - Check for stuck tasks that aren't completing + +### Debugging + +Enable verbose logging: +```yaml +env: + - name: LOG_LEVEL + value: "debug" +``` + +Check webhook deliveries in GitHub: +- Repository Settings → Webhooks → Recent Deliveries +- Shows request/response details and retry attempts \ No newline at end of file diff --git a/examples/10-taskspawner-github-webhook/taskspawner.yaml b/examples/10-taskspawner-github-webhook/taskspawner.yaml new file mode 100644 index 00000000..1a8825e5 --- /dev/null +++ b/examples/10-taskspawner-github-webhook/taskspawner.yaml @@ -0,0 +1,132 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: github-webhook-responder + namespace: default +spec: + # Respond to GitHub webhook events + when: + githubWebhook: + # Listen for these GitHub event types + events: + - "issues" + - "pull_request" + - "issue_comment" + - "pull_request_review" + + # Optional filters to refine which events trigger tasks + # Multiple filters use OR semantics within the same event type + filters: + # Respond to newly opened issues + - event: "issues" + action: "opened" + + # Respond to pull requests opened or ready for review + - event: "pull_request" + action: "opened" + draft: false # Only non-draft PRs + + # Respond to pull requests that get the "review-requested" label + - event: "pull_request" + action: "labeled" + labels: ["review-requested"] + + # Respond to issue comments containing "/kelos" + - event: "issue_comment" + action: "created" + bodyContains: "/kelos" + + # Respond to PR review requests from specific author + - event: "pull_request_review" + action: "submitted" + author: "senior-reviewer" + + # Task template configuration + taskTemplate: + # Agent type and credentials + type: claude-code + credentials: + type: api-key + secretRef: + name: claude-credentials + key: api-key + + # Reference the repository workspace + workspaceRef: + name: my-repo-workspace + + # Template for the git branch (optional) + branch: "kelos-webhook-{{.Event}}-{{.ID}}" + + # Template for the task prompt + promptTemplate: | + # GitHub {{.Event | title}} Event: {{.Action}} + + A GitHub webhook event has been triggered that requires attention. + + ## Event Details + - **Event Type**: {{.Event}} + - **Action**: {{.Action}} + - **Triggered by**: @{{.Sender}} + {{- if .Title}} + - **Title**: {{.Title}} + {{- end}} + {{- if .URL}} + - **URL**: {{.URL}} + {{- end}} + {{- if .Branch}} + - **Branch**: {{.Branch}} + {{- end}} + + {{- if eq .Event "issues"}} + ## Issue Description + {{.Body}} + + ## Task + Please review this issue and provide an initial analysis or triage. + {{- else if eq .Event "pull_request"}} + ## Pull Request Description + {{.Body}} + + ## Task + Please review this pull request and provide feedback on the code changes. + {{- else if eq .Event "issue_comment"}} + ## Task + A comment was made that mentioned our bot. Please review the comment and respond appropriately. + {{- else if eq .Event "pull_request_review"}} + ## Task + A pull request review was submitted. Please check if any follow-up actions are needed. + {{- end}} + + You have full access to the repository through the workspace. Use git commands and code analysis tools as needed. + + # Task metadata (optional) + metadata: + labels: + kelos.dev/trigger: "webhook" + kelos.dev/event: "{{.Event}}" + kelos.dev/action: "{{.Action}}" + annotations: + kelos.dev/github-url: "{{.URL}}" + kelos.dev/github-sender: "{{.Sender}}" + + # Auto-cleanup completed tasks after 1 hour + ttlSecondsAfterFinished: 3600 + + # Limit concurrent tasks to prevent overwhelming the system + maxConcurrency: 5 + +--- +# Example workspace configuration (referenced above) +apiVersion: kelos.dev/v1alpha1 +kind: Workspace +metadata: + name: my-repo-workspace + namespace: default +spec: + repo: + url: "https://github.com/myorg/myrepo.git" + # Optional: reference secret for private repos + # secretRef: + # name: github-token + # key: token diff --git a/examples/11-taskspawner-linear-webhook/README.md b/examples/11-taskspawner-linear-webhook/README.md new file mode 100644 index 00000000..1b5d6c97 --- /dev/null +++ b/examples/11-taskspawner-linear-webhook/README.md @@ -0,0 +1,214 @@ +# Linear Webhook TaskSpawner Example + +This example demonstrates how to configure a TaskSpawner to respond to Linear webhook events. + +## Overview + +The Linear webhook TaskSpawner triggers task creation based on Linear workspace events like: +- Issues being created, updated, or deleted +- Comments being added or modified +- Projects being updated +- Issue state changes and label assignments + +## Prerequisites + +1. **Webhook Server**: Deploy the kelos-webhook-server with Linear source configuration +2. **Linear Webhook**: Configure your Linear workspace to send webhooks to your Kelos instance +3. **Secret**: Create a Kubernetes secret containing the webhook signing secret + +## Setup + +### 1. Create Webhook Secret + +```bash +kubectl create secret generic linear-webhook-secret \ + --from-literal=WEBHOOK_SECRET=your-linear-webhook-secret +``` + +### 2. Configure Linear Workspace Webhook + +In your Linear workspace settings: +1. Go to Settings → API → Webhooks → Create webhook +2. Set URL to: `https://your-kelos-instance.com/webhook/linear` +3. Set Secret to the same value as in your Kubernetes secret +4. Select the resource types you want to receive (Issues, Comments, etc.) +5. Enable the webhook + +### 3. Deploy TaskSpawner + +Apply the TaskSpawner configuration: + +```bash +kubectl apply -f taskspawner.yaml +``` + +## Configuration Details + +The example TaskSpawner demonstrates several filtering patterns: + +- **Resource Types**: Only responds to `Issue`, `Comment`, and `IssueLabel` events +- **Action Filtering**: Responds to specific actions like "create", "update", etc. +- **State Filtering**: Can filter by Linear workflow states +- **Label Requirements**: Can require or exclude specific labels +- **OR Semantics**: Multiple filters for same type use OR logic + +## Template Variables + +Linear webhook events provide template variables for task creation: + +### Standard Variables +- `{{.Type}}` - Linear resource type (e.g., "Issue", "Comment") +- `{{.Action}}` - Webhook action (e.g., "create", "update", "remove") +- `{{.ID}}` - Resource ID +- `{{.Title}}` - Issue title (when available) +- `{{.Payload}}` - Full webhook payload for accessing any field + +### Raw Payload Access +- `{{.Payload.data.description}}` - Issue description +- `{{.Payload.data.state.name}}` - Issue state name +- `{{.Payload.data.assignee.name}}` - Assignee name +- `{{.Payload.data.labels}}` - Array of labels +- `{{.Payload.data.team.name}}` - Team name + +Example template usage: +```yaml +promptTemplate: | + A Linear {{.Type}} event occurred in the workspace. + + Type: {{.Type}} + Action: {{.Action}} + {{if .Title}}Title: {{.Title}}{{end}} + + {{if eq .Type "Issue"}} + ## Issue Details + - **State**: {{.Payload.data.state.name}} + - **Team**: {{.Payload.data.team.name}} + {{if .Payload.data.assignee}}- **Assignee**: {{.Payload.data.assignee.name}}{{end}} + {{if .Payload.data.labels}}- **Labels**: {{range .Payload.data.labels}}{{.name}} {{end}}{{end}} + + ## Description + {{.Payload.data.description}} + {{end}} + + Please analyze this issue and provide recommendations. + +branch: "linear-{{.Type}}-{{.ID}}" +``` + +## Webhook Security + +The webhook server validates Linear signatures using HMAC-SHA256: +- Linear sends signatures in `Linear-Signature` header as raw hex digest +- The server validates against the secret stored in `WEBHOOK_SECRET` env var +- Invalid signatures result in HTTP 401 responses + +## Linear Webhook Format + +Linear webhooks follow this general structure: + +```json +{ + "type": "Issue", + "action": "create", + "data": { + "id": "issue-id", + "title": "Issue title", + "description": "Issue description", + "state": { + "id": "state-id", + "name": "Todo" + }, + "team": { + "id": "team-id", + "name": "Engineering" + }, + "assignee": { + "id": "user-id", + "name": "John Doe" + }, + "labels": [ + { + "id": "label-id", + "name": "bug" + } + ] + } +} +``` + +## Scaling and Reliability + +### Concurrency Control +- Set `maxConcurrency` to limit parallel tasks from webhook events +- When exceeded, returns HTTP 503 with `Retry-After` header +- Linear will automatically retry failed webhook deliveries + +### Idempotency +- Webhook deliveries are tracked by `Linear-Delivery` header +- Duplicate deliveries (e.g., retries) are ignored +- Delivery cache entries expire after 24 hours + +### Fault Isolation +- Per-source webhook servers provide fault isolation +- Linear webhook failures don't affect GitHub or other sources +- Each source can be scaled independently + +## Common Linear Event Types + +### Issues +- **create**: New issue created +- **update**: Issue updated (title, description, state, assignee, etc.) +- **remove**: Issue deleted + +### Comments +- **create**: New comment added +- **update**: Comment edited +- **remove**: Comment deleted + +### IssueLabel +- **create**: Label added to issue +- **remove**: Label removed from issue + +## Troubleshooting + +### Common Issues + +1. **Tasks not being created** + - Check webhook server logs for signature validation errors + - Verify Linear webhook is configured with correct URL and secret + - Check TaskSpawner resource type and filter configuration + +2. **Signature validation failures** + - Ensure WEBHOOK_SECRET matches Linear webhook secret exactly + - Check for trailing newlines or encoding issues in secret + +3. **Filtering not working** + - Linear webhook payloads have nested structure (`data` object) + - Verify filter values match exact Linear state/label names + - Use Linear webhook logs to see actual payload structure + +### Debugging + +Enable verbose logging: +```yaml +env: + - name: LOG_LEVEL + value: "debug" +``` + +Check webhook deliveries in Linear: +- Settings → API → Webhooks → View webhook → Recent deliveries +- Shows request/response details and retry attempts + +### Testing Webhooks + +You can test webhook functionality using curl: + +```bash +# Test Linear webhook with valid signature +curl -X POST https://your-kelos-instance.com/webhook/linear \ + -H "Content-Type: application/json" \ + -H "Linear-Signature: your-hmac-signature" \ + -H "Linear-Delivery: test-delivery-123" \ + -d '{"type":"Issue","action":"create","data":{"id":"test-123","title":"Test Issue"}}' +``` \ 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..25b82a3e --- /dev/null +++ b/examples/11-taskspawner-linear-webhook/taskspawner.yaml @@ -0,0 +1,172 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: linear-webhook-responder + namespace: default +spec: + # Respond to Linear webhook events + when: + linearWebhook: + # Listen for these Linear resource types + types: + - "Issue" + - "Comment" + - "IssueLabel" + + # Optional filters to refine which events trigger tasks + # Multiple filters use OR semantics within the same resource type + filters: + # Respond to newly created issues + - type: "Issue" + action: "create" + + # Respond to issues moved to "In Progress" or "In Review" states + - type: "Issue" + action: "update" + states: ["In Progress", "In Review"] + + # Respond to issues with "priority:high" label + - type: "Issue" + labels: ["priority:high"] + + # Respond to issues with "bug" label but exclude "wontfix" + - type: "Issue" + labels: ["bug"] + excludeLabels: ["wontfix"] + + # Respond to new comments on issues + - type: "Comment" + action: "create" + + # Respond when "urgent" label is added to issues + - type: "IssueLabel" + action: "create" + # Note: For IssueLabel events, filter by the label name in the payload + + # Task template configuration + taskTemplate: + # Agent type and credentials + type: claude-code + credentials: + type: api-key + secretRef: + name: claude-credentials + key: api-key + + # Reference the repository workspace + workspaceRef: + name: my-repo-workspace + + # Template for the git branch (optional) + branch: "linear-{{.Type}}-{{.ID}}" + + # Template for the task prompt + promptTemplate: | + # Linear {{.Type}} Event: {{.Action}} + + A Linear webhook event has been triggered that requires attention. + + ## Event Details + - **Type**: {{.Type}} + - **Action**: {{.Action}} + {{- if .Title}} + - **Title**: {{.Title}} + {{- end}} + - **ID**: {{.ID}} + + {{- if eq .Type "Issue"}} + ## Issue Information + {{- if .Payload.data.description}} + **Description**: {{.Payload.data.description}} + {{- end}} + {{- if .Payload.data.state}} + **State**: {{.Payload.data.state.name}} + {{- end}} + {{- if .Payload.data.team}} + **Team**: {{.Payload.data.team.name}} + {{- end}} + {{- if .Payload.data.assignee}} + **Assignee**: {{.Payload.data.assignee.name}} + {{- end}} + {{- if .Payload.data.labels}} + **Labels**: {{range .Payload.data.labels}}{{.name}} {{end}} + {{- end}} + {{- if .Payload.data.url}} + **URL**: {{.Payload.data.url}} + {{- end}} + + ## Task + {{- if eq .Action "create"}} + A new issue has been created. Please review the requirements and provide an initial assessment or implementation plan. + {{- else if and (eq .Action "update") (and .Payload.data.state (or (eq .Payload.data.state.name "In Progress") (eq .Payload.data.state.name "In Review")))}} + This issue has been moved to {{.Payload.data.state.name}} state. Please review the current implementation and provide feedback or continue the work. + {{- else if .Payload.data.labels}} + This issue has been updated with priority labels. Please review and prioritize accordingly. + {{- else}} + This issue has been updated. Please review the changes and take appropriate action. + {{- end}} + + {{- else if eq .Type "Comment"}} + ## Comment Information + {{- if .Payload.data.body}} + **Comment**: {{.Payload.data.body}} + {{- end}} + {{- if .Payload.data.user}} + **Author**: {{.Payload.data.user.name}} + {{- end}} + {{- if .Payload.data.issue}} + **Issue**: {{.Payload.data.issue.title}} + {{- end}} + + ## Task + A new comment has been added to an issue. Please review the comment and respond if necessary. + + {{- else if eq .Type "IssueLabel"}} + ## Label Change + {{- if .Payload.data.label}} + **Label**: {{.Payload.data.label.name}} + {{- end}} + {{- if .Payload.data.issue}} + **Issue**: {{.Payload.data.issue.title}} + {{- end}} + + ## Task + {{- if eq .Action "create"}} + A label has been added to an issue. Please review if this requires immediate attention. + {{- else}} + A label has been removed from an issue. Please review the change and take appropriate action. + {{- end}} + {{- end}} + + You have full access to the repository through the workspace. Use git commands and analysis tools as needed. + + # Task metadata (optional) + metadata: + labels: + kelos.dev/trigger: "webhook" + kelos.dev/source: "linear" + kelos.dev/type: "{{.Type}}" + kelos.dev/action: "{{.Action}}" + annotations: + kelos.dev/linear-id: "{{.ID}}" + + # Auto-cleanup completed tasks after 2 hours + ttlSecondsAfterFinished: 7200 + + # Limit concurrent tasks to prevent overwhelming the system + maxConcurrency: 3 + +--- +# Example workspace configuration (referenced above) +apiVersion: kelos.dev/v1alpha1 +kind: Workspace +metadata: + name: my-repo-workspace + namespace: default +spec: + repo: + url: "https://github.com/myorg/myrepo.git" + # Optional: reference secret for private repos + # secretRef: + # name: github-token + # key: token diff --git a/examples/helm-values-webhook.yaml b/examples/helm-values-webhook.yaml new file mode 100644 index 00000000..ea5270bf --- /dev/null +++ b/examples/helm-values-webhook.yaml @@ -0,0 +1,34 @@ +# Example Helm values to enable webhook servers + +# Enable webhook servers for both GitHub and Linear +webhookServer: + sources: + # Enable GitHub webhook server + github: + enabled: true + replicas: 2 # For high availability + secretName: github-webhook-secret # Must contain WEBHOOK_SECRET key + + # Enable Linear webhook server + linear: + enabled: true + replicas: 1 + secretName: linear-webhook-secret # Must contain WEBHOOK_SECRET key + + # Enable ingress for external webhook access + ingress: + enabled: true + className: "nginx" # Or your ingress class + host: "webhooks.your-domain.com" + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + +# Use versioned images for production +image: + tag: "v1.0.0" # Replace with your desired version + pullPolicy: "IfNotPresent" + +# Enable telemetry for monitoring +telemetry: + enabled: true \ No newline at end of file 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/cli/webhook_test.go b/internal/cli/webhook_test.go new file mode 100644 index 00000000..71100b57 --- /dev/null +++ b/internal/cli/webhook_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/kelos-dev/kelos/internal/helmchart" + "github.com/kelos-dev/kelos/internal/manifests" +) + +func TestRenderChart_WebhookServers(t *testing.T) { + vals := map[string]interface{}{ + "webhookServer": map[string]interface{}{ + "image": "ghcr.io/kelos-dev/kelos-webhook-server", + "sources": map[string]interface{}{ + "github": map[string]interface{}{ + "enabled": true, + "replicas": 1, + "secretName": "github-webhook-secret", + }, + "linear": map[string]interface{}{ + "enabled": true, + "replicas": 1, + "secretName": "linear-webhook-secret", + }, + }, + "ingress": map[string]interface{}{ + "enabled": true, + "className": "nginx", + "host": "webhooks.example.com", + }, + }, + "image": map[string]interface{}{ + "tag": "latest", + }, + } + + data, err := helmchart.Render(manifests.ChartFS, vals) + if err != nil { + t.Fatalf("rendering chart: %v", err) + } + + content := string(data) + + // Check for webhook components + expectedComponents := []string{ + "name: kelos-webhook-github", + "name: kelos-webhook-linear", + "kind: Ingress", + "name: kelos-webhook-role", + "name: kelos-webhook", + "app.kubernetes.io/component: webhook-github", + "app.kubernetes.io/component: webhook-linear", + "--source=github", + "--source=linear", + "github-webhook-secret", + "linear-webhook-secret", + } + + for _, component := range expectedComponents { + if !strings.Contains(content, component) { + t.Errorf("expected rendered chart to contain %q", component) + } + } +} + +func TestRenderChart_WebhookServersDisabled(t *testing.T) { + vals := map[string]interface{}{ + "webhookServer": map[string]interface{}{ + "sources": map[string]interface{}{ + "github": map[string]interface{}{ + "enabled": false, + }, + "linear": map[string]interface{}{ + "enabled": false, + }, + }, + }, + "image": map[string]interface{}{ + "tag": "latest", + }, + } + + data, err := helmchart.Render(manifests.ChartFS, vals) + if err != nil { + t.Fatalf("rendering chart: %v", err) + } + + content := string(data) + + // Should not contain webhook components when disabled + unexpectedComponents := []string{ + "name: kelos-webhook-github", + "name: kelos-webhook-linear", + "--source=github", + "--source=linear", + } + + for _, component := range unexpectedComponents { + if strings.Contains(content, component) { + t.Errorf("did not expect rendered chart to contain %q when webhooks disabled", component) + } + } +} \ No newline at end of file diff --git a/internal/manifests/charts/kelos/README.md b/internal/manifests/charts/kelos/README.md index 3b8b99a0..e419be8b 100644 --- a/internal/manifests/charts/kelos/README.md +++ b/internal/manifests/charts/kelos/README.md @@ -100,3 +100,72 @@ helm uninstall kelos -n kelos-system ``` Because `crds.keep=true` by default, uninstalling the chart does not delete the Kelos CRDs. + +## Webhook Server Configuration + +The chart includes optional webhook servers for GitHub and Linear integration. These are disabled by default and must be explicitly enabled. + +### Prerequisites + +1. Create secrets containing webhook signing secrets: + +```bash +# GitHub webhook secret +kubectl create secret generic github-webhook-secret \ + --from-literal=WEBHOOK_SECRET=your-github-webhook-secret \ + -n kelos-system + +# Linear webhook secret +kubectl create secret generic linear-webhook-secret \ + --from-literal=WEBHOOK_SECRET=your-linear-webhook-secret \ + -n kelos-system +``` + +2. Configure webhooks in your GitHub repositories or Linear workspace to send events to your webhook endpoints. + +### Enable Webhook Servers + +```bash +helm upgrade --install kelos oci://ghcr.io/kelos-dev/charts/kelos \ + -n kelos-system \ + --create-namespace \ + --set webhookServer.sources.github.enabled=true \ + --set webhookServer.sources.github.secretName=github-webhook-secret \ + --set webhookServer.sources.linear.enabled=true \ + --set webhookServer.sources.linear.secretName=linear-webhook-secret \ + --set webhookServer.ingress.enabled=true \ + --set webhookServer.ingress.host=webhooks.your-domain.com \ + --set webhookServer.ingress.className=nginx +``` + +### Webhook Configuration Options + +```yaml +webhookServer: + image: ghcr.io/kelos-dev/kelos-webhook-server + sources: + github: + enabled: false # Enable GitHub webhook server + replicas: 1 # Number of replicas + secretName: "" # Secret containing WEBHOOK_SECRET + linear: + enabled: false # Enable Linear webhook server + replicas: 1 # Number of replicas + secretName: "" # Secret containing WEBHOOK_SECRET + ingress: + enabled: false # Enable ingress for external access + className: "" # Ingress class name (e.g., nginx) + host: "" # Hostname for webhook endpoints + annotations: {} # Additional ingress annotations +``` + +### Webhook Endpoints + +When enabled, the webhook servers expose the following endpoints: + +- **GitHub**: `https://your-host/webhook/github` +- **Linear**: `https://your-host/webhook/linear` + +### Example Values File + +See `examples/helm-values-webhook.yaml` for a complete example configuration. diff --git a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml index 25d9875a..a9d5837b 100644 --- a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml @@ -117,6 +117,8 @@ spec: Available variables (all sources): {{ "{{.ID}}" }}, {{ "{{.Title}}" }}, {{ "{{.Kind}}" }} GitHub issue/Jira sources: {{ "{{.Number}}" }}, {{ "{{.Body}}" }}, {{ "{{.URL}}" }}, {{ "{{.Labels}}" }}, {{ "{{.Comments}}" }} GitHub pull request sources additionally expose: {{ "{{.Branch}}" }}, {{ "{{.ReviewState}}" }}, {{ "{{.ReviewComments}}" }} + GitHub webhook sources: {{ "{{.Event}}" }}, {{ "{{.Action}}" }}, {{ "{{.Sender}}" }}, {{ "{{.Ref}}" }}, {{ "{{.Payload}}" }} (full payload access) + Linear webhook sources: {{ "{{.Type}}" }}, {{ "{{.Action}}" }}, {{ "{{.Payload}}" }} (full payload access) Cron sources: {{ "{{.Time}}" }}, {{ "{{.Schedule}}" }} type: string credentials: @@ -438,6 +440,8 @@ spec: Available variables (all sources): {{ "{{.ID}}" }}, {{ "{{.Title}}" }}, {{ "{{.Kind}}" }} GitHub issue/Jira sources: {{ "{{.Number}}" }}, {{ "{{.Body}}" }}, {{ "{{.URL}}" }}, {{ "{{.Labels}}" }}, {{ "{{.Comments}}" }} GitHub pull request sources additionally expose: {{ "{{.Branch}}" }}, {{ "{{.ReviewState}}" }}, {{ "{{.ReviewComments}}" }} + GitHub webhook sources: {{ "{{.Event}}" }}, {{ "{{.Action}}" }}, {{ "{{.Sender}}" }}, {{ "{{.Ref}}" }}, {{ "{{.Payload}}" }} (full payload access) + Linear webhook sources: {{ "{{.Type}}" }}, {{ "{{.Action}}" }}, {{ "{{.Payload}}" }} (full payload access) Cron sources: {{ "{{.Time}}" }}, {{ "{{.Schedule}}" }} type: string ttlSecondsAfterFinished: @@ -792,6 +796,67 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: GitHubWebhook triggers task spawning on GitHub webhook + events. + properties: + events: + description: |- + Events is the list of GitHub event types to listen for. + e.g., "issue_comment", "pull_request_review", "push", "issues" + items: + type: string + minItems: 1 + type: array + filters: + description: |- + 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. + items: + description: GitHubWebhookFilter defines filtering criteria + for GitHub webhook events. + 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/review body. + type: string + branch: + description: Branch filters push events by branch name + (exact match or glob). + type: string + draft: + description: Draft filters PRs by draft status. nil + = don't filter. + type: boolean + event: + description: Event is the GitHub event type this filter + applies to. + type: string + labels: + description: Labels requires the issue/PR to have all + of these labels. + items: + type: string + type: array + state: + description: State filters by issue/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,16 +896,69 @@ spec: - project - secretRef type: object + linearWebhook: + description: LinearWebhook triggers task spawning on Linear webhook + events. + properties: + filters: + description: |- + Filters refine which events trigger tasks (OR semantics within same type). + If empty, all events in the Types list trigger tasks. + items: + description: LinearWebhookFilter defines filtering criteria + for Linear webhook events. + 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 - when type: object x-kubernetes-validations: - - message: taskTemplate.workspaceRef is required when using githubIssues - or githubPullRequests source - rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) - || has(self.taskTemplate.workspaceRef)' + - message: taskTemplate.workspaceRef is required when using githubIssues, + githubPullRequests, or githubWebhook source + rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests) + || has(self.when.githubWebhook)) || has(self.taskTemplate.workspaceRef)' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: diff --git a/internal/manifests/charts/kelos/templates/rbac.yaml b/internal/manifests/charts/kelos/templates/rbac.yaml index 2d1a6e1d..845765b3 100644 --- a/internal/manifests/charts/kelos/templates/rbac.yaml +++ b/internal/manifests/charts/kelos/templates/rbac.yaml @@ -217,3 +217,47 @@ subjects: - kind: ServiceAccount name: kelos-controller namespace: kelos-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kelos-webhook-role +rules: + - apiGroups: + - kelos.dev + resources: + - taskspawners + verbs: + - get + - list + - watch + - apiGroups: + - kelos.dev + resources: + - tasks + verbs: + - create + - get + - list + - apiGroups: + - kelos.dev + resources: + - agentconfigs + - workspaces + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kelos-webhook-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kelos-webhook-role +subjects: + - kind: ServiceAccount + name: kelos-webhook + namespace: kelos-system diff --git a/internal/manifests/charts/kelos/templates/serviceaccount.yaml b/internal/manifests/charts/kelos/templates/serviceaccount.yaml index 915109a9..377c2587 100644 --- a/internal/manifests/charts/kelos/templates/serviceaccount.yaml +++ b/internal/manifests/charts/kelos/templates/serviceaccount.yaml @@ -4,3 +4,9 @@ kind: ServiceAccount metadata: name: kelos-controller namespace: kelos-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook + namespace: kelos-system 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..aa8fe265 --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-ingress.yaml @@ -0,0 +1,45 @@ +{{- if .Values.webhookServer.ingress.enabled }} +{{- $githubEnabled := .Values.webhookServer.sources.github.enabled }} +{{- $linearEnabled := .Values.webhookServer.sources.linear.enabled }} +{{- if or $githubEnabled $linearEnabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kelos-webhook + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-ingress + {{- with .Values.webhookServer.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.webhookServer.ingress.className }} + ingressClassName: {{ .Values.webhookServer.ingress.className }} + {{- end }} + rules: + - host: {{ .Values.webhookServer.ingress.host }} + http: + paths: + {{- if $githubEnabled }} + - path: /webhook/github + pathType: Prefix + backend: + service: + name: kelos-webhook-github + port: + number: 8443 + {{- end }} + {{- if $linearEnabled }} + - path: /webhook/linear + pathType: Prefix + backend: + service: + name: kelos-webhook-linear + port: + number: 8443 + {{- end }} +{{- end }} +{{- end }} \ No newline at end of file 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..9180ba4b --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-server.yaml @@ -0,0 +1,201 @@ +{{- if .Values.webhookServer.sources.github.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-github + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-github +spec: + replicas: {{ .Values.webhookServer.sources.github.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-github + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-github + spec: + serviceAccountName: kelos-webhook + 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=github + - --webhook-bind-address=:8443 + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:8081 + env: + - name: WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.webhookServer.sources.github.secretName }} + key: WEBHOOK_SECRET + ports: + - name: webhook + containerPort: 8443 + protocol: TCP + - name: metrics + containerPort: 8080 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-github + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-github +spec: + type: ClusterIP + ports: + - name: webhook + port: 8443 + targetPort: webhook + protocol: TCP + - name: metrics + port: 8080 + targetPort: metrics + protocol: TCP + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-github +{{- end }} + +{{- if .Values.webhookServer.sources.linear.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-linear + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-linear +spec: + replicas: {{ .Values.webhookServer.sources.linear.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-linear + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-linear + spec: + serviceAccountName: kelos-webhook + 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=linear + - --webhook-bind-address=:8443 + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:8081 + env: + - name: WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.webhookServer.sources.linear.secretName }} + key: WEBHOOK_SECRET + ports: + - name: webhook + containerPort: 8443 + protocol: TCP + - name: metrics + containerPort: 8080 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-linear + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-linear +spec: + type: ClusterIP + ports: + - name: webhook + port: 8443 + targetPort: webhook + protocol: TCP + - name: metrics + port: 8080 + targetPort: metrics + protocol: TCP + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-linear +{{- end }} \ No newline at end of file diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index f50003b0..892808c1 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -32,3 +32,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..bb997dd5 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -796,6 +796,8 @@ spec: Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access) + Linear webhook sources: {{.Type}}, {{.Action}}, {{.Payload}} (full payload access) Cron sources: {{.Time}}, {{.Schedule}} type: string credentials: @@ -1117,6 +1119,8 @@ spec: Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access) + Linear webhook sources: {{.Type}}, {{.Action}}, {{.Payload}} (full payload access) Cron sources: {{.Time}}, {{.Schedule}} type: string ttlSecondsAfterFinished: @@ -1471,6 +1475,67 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: GitHubWebhook triggers task spawning on GitHub webhook + events. + properties: + events: + description: |- + Events is the list of GitHub event types to listen for. + e.g., "issue_comment", "pull_request_review", "push", "issues" + items: + type: string + minItems: 1 + type: array + filters: + description: |- + 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. + items: + description: GitHubWebhookFilter defines filtering criteria + for GitHub webhook events. + 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/review body. + type: string + branch: + description: Branch filters push events by branch name + (exact match or glob). + type: string + draft: + description: Draft filters PRs by draft status. nil + = don't filter. + type: boolean + event: + description: Event is the GitHub event type this filter + applies to. + type: string + labels: + description: Labels requires the issue/PR to have all + of these labels. + items: + type: string + type: array + state: + description: State filters by issue/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,16 +1575,69 @@ spec: - project - secretRef type: object + linearWebhook: + description: LinearWebhook triggers task spawning on Linear webhook + events. + properties: + filters: + description: |- + Filters refine which events trigger tasks (OR semantics within same type). + If empty, all events in the Types list trigger tasks. + items: + description: LinearWebhookFilter defines filtering criteria + for Linear webhook events. + 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 - when type: object x-kubernetes-validations: - - message: taskTemplate.workspaceRef is required when using githubIssues - or githubPullRequests source - rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) - || has(self.taskTemplate.workspaceRef)' + - message: taskTemplate.workspaceRef is required when using githubIssues, + githubPullRequests, or githubWebhook source + rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests) + || has(self.when.githubWebhook)) || has(self.taskTemplate.workspaceRef)' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: diff --git a/internal/taskbuilder/builder.go b/internal/taskbuilder/builder.go new file mode 100644 index 00000000..1e2f1cdb --- /dev/null +++ b/internal/taskbuilder/builder.go @@ -0,0 +1,141 @@ +package taskbuilder + +import ( + "bytes" + "fmt" + "text/template" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// TaskBuilder creates Tasks from templates and work item data. +type TaskBuilder struct { + client client.Client +} + +// NewTaskBuilder creates a new task builder. +func NewTaskBuilder(client client.Client) (*TaskBuilder, error) { + return &TaskBuilder{ + client: client, + }, nil +} + +// BuildTask creates a Task from a template and template variables. +func (tb *TaskBuilder) BuildTask( + name, namespace string, + taskTemplate *v1alpha1.TaskTemplate, + templateVars map[string]interface{}, +) (*v1alpha1.Task, error) { + // Render the prompt template + promptTemplate := taskTemplate.PromptTemplate + if promptTemplate == "" { + promptTemplate = "{{.Title}}" // Default template + } + + prompt, err := renderTemplate("prompt", promptTemplate, templateVars) + if err != nil { + return nil, fmt.Errorf("failed to render prompt template: %w", err) + } + + // Render the branch template + branch := taskTemplate.Branch + if branch != "" { + branch, err = renderTemplate("branch", branch, templateVars) + if err != nil { + return nil, fmt.Errorf("failed to render branch template: %w", err) + } + } + + // Create the Task + task := &v1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha1.TaskSpec{ + Type: taskTemplate.Type, + Credentials: taskTemplate.Credentials, + Prompt: prompt, + }, + } + + // Set optional fields + if taskTemplate.Model != "" { + task.Spec.Model = taskTemplate.Model + } + if taskTemplate.Image != "" { + task.Spec.Image = taskTemplate.Image + } + if taskTemplate.WorkspaceRef != nil { + task.Spec.WorkspaceRef = taskTemplate.WorkspaceRef + } + if taskTemplate.AgentConfigRef != nil { + task.Spec.AgentConfigRef = taskTemplate.AgentConfigRef + } + if len(taskTemplate.DependsOn) > 0 { + task.Spec.DependsOn = taskTemplate.DependsOn + } + if branch != "" { + task.Spec.Branch = branch + } + if taskTemplate.TTLSecondsAfterFinished != nil { + task.Spec.TTLSecondsAfterFinished = taskTemplate.TTLSecondsAfterFinished + } + if taskTemplate.PodOverrides != nil { + task.Spec.PodOverrides = taskTemplate.PodOverrides + } + if taskTemplate.UpstreamRepo != "" { + task.Spec.UpstreamRepo = taskTemplate.UpstreamRepo + } + + // Apply template metadata + if taskTemplate.Metadata != nil { + // Render labels + if len(taskTemplate.Metadata.Labels) > 0 { + if task.Labels == nil { + task.Labels = make(map[string]string) + } + for key, valueTemplate := range taskTemplate.Metadata.Labels { + value, err := renderTemplate(fmt.Sprintf("label[%s]", key), valueTemplate, templateVars) + if err != nil { + return nil, fmt.Errorf("failed to render label %s: %w", key, err) + } + task.Labels[key] = value + } + } + + // Render annotations + if len(taskTemplate.Metadata.Annotations) > 0 { + if task.Annotations == nil { + task.Annotations = make(map[string]string) + } + for key, valueTemplate := range taskTemplate.Metadata.Annotations { + value, err := renderTemplate(fmt.Sprintf("annotation[%s]", key), valueTemplate, templateVars) + if err != nil { + return nil, fmt.Errorf("failed to render annotation %s: %w", key, err) + } + task.Annotations[key] = value + } + } + } + + return task, nil +} + +// renderTemplate renders a Go text template with the given variables. +func renderTemplate(name, templateStr string, vars map[string]interface{}) (string, error) { + tmpl, err := template.New(name).Parse(templateStr) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, vars); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/webhook/github_filter.go b/internal/webhook/github_filter.go new file mode 100644 index 00000000..fc789ec8 --- /dev/null +++ b/internal/webhook/github_filter.go @@ -0,0 +1,330 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/google/go-github/v66/github" + + "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// GitHubEventData holds parsed GitHub event information for template rendering. +type GitHubEventData struct { + // Event type (e.g., "issues", "pull_request", "push") + Event string + // Action (e.g., "opened", "created", "submitted") + Action string + // Sender username + Sender string + // Git ref for push events + Ref string + // Raw parsed event payload for template access + RawEvent interface{} + // Standard template variables for compatibility + ID string + Title string + Number int + Body string + URL string + Branch string +} + +// ParseGitHubWebhook parses a GitHub webhook payload using the go-github SDK. +func ParseGitHubWebhook(eventType string, payload []byte) (*GitHubEventData, error) { + event, err := github.ParseWebHook(eventType, payload) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub webhook: %w", err) + } + + data := &GitHubEventData{ + Event: eventType, + RawEvent: event, + } + + // Extract common fields based on event type + switch e := event.(type) { + case *github.IssuesEvent: + data.Action = e.GetAction() + data.Sender = e.GetSender().GetLogin() + if issue := e.GetIssue(); issue != nil { + data.ID = fmt.Sprintf("%d", issue.GetNumber()) + data.Title = issue.GetTitle() + data.Number = issue.GetNumber() + data.Body = issue.GetBody() + data.URL = issue.GetHTMLURL() + } + + case *github.PullRequestEvent: + data.Action = e.GetAction() + data.Sender = e.GetSender().GetLogin() + if pr := e.GetPullRequest(); pr != nil { + data.ID = fmt.Sprintf("%d", pr.GetNumber()) + data.Title = pr.GetTitle() + data.Number = pr.GetNumber() + data.Body = pr.GetBody() + data.URL = pr.GetHTMLURL() + if head := pr.GetHead(); head != nil { + data.Branch = head.GetRef() + } + } + + case *github.IssueCommentEvent: + data.Action = e.GetAction() + data.Sender = e.GetSender().GetLogin() + if issue := e.GetIssue(); issue != nil { + data.ID = fmt.Sprintf("%d", issue.GetNumber()) + data.Title = issue.GetTitle() + data.Number = issue.GetNumber() + data.Body = issue.GetBody() + data.URL = issue.GetHTMLURL() + } + + case *github.PullRequestReviewEvent: + data.Action = e.GetAction() + data.Sender = e.GetSender().GetLogin() + if pr := e.GetPullRequest(); pr != nil { + data.ID = fmt.Sprintf("%d", pr.GetNumber()) + data.Title = pr.GetTitle() + data.Number = pr.GetNumber() + data.Body = pr.GetBody() + data.URL = pr.GetHTMLURL() + if head := pr.GetHead(); head != nil { + data.Branch = head.GetRef() + } + } + + case *github.PullRequestReviewCommentEvent: + data.Action = e.GetAction() + data.Sender = e.GetSender().GetLogin() + if pr := e.GetPullRequest(); pr != nil { + data.ID = fmt.Sprintf("%d", pr.GetNumber()) + data.Title = pr.GetTitle() + data.Number = pr.GetNumber() + data.Body = pr.GetBody() + data.URL = pr.GetHTMLURL() + if head := pr.GetHead(); head != nil { + data.Branch = head.GetRef() + } + } + + case *github.PushEvent: + data.Sender = e.GetSender().GetLogin() + data.Ref = e.GetRef() + // Extract branch name from refs/heads/branch-name + if strings.HasPrefix(data.Ref, "refs/heads/") { + data.Branch = strings.TrimPrefix(data.Ref, "refs/heads/") + } + data.ID = e.GetHeadCommit().GetID() + data.Title = fmt.Sprintf("Push to %s", data.Branch) + + default: + // For other event types, try to extract sender from raw JSON + var raw map[string]interface{} + if err := json.Unmarshal(payload, &raw); err == nil { + if sender, ok := raw["sender"].(map[string]interface{}); ok { + if login, ok := sender["login"].(string); ok { + data.Sender = login + } + } + if action, ok := raw["action"].(string); ok { + data.Action = action + } + } + } + + return data, nil +} + +// MatchesGitHubEvent evaluates whether a GitHub webhook event matches the spawner's filters. +func MatchesGitHubEvent(spawner *v1alpha1.GitHubWebhook, eventType string, payload []byte) (bool, error) { + // Check if event type is in the allowed list + eventAllowed := false + for _, allowedEvent := range spawner.Events { + if allowedEvent == eventType { + eventAllowed = true + break + } + } + if !eventAllowed { + return false, nil + } + + // If no filters, all events of the allowed types match + if len(spawner.Filters) == 0 { + return true, nil + } + + // Parse the event for filtering + eventData, err := ParseGitHubWebhook(eventType, payload) + if err != nil { + return false, fmt.Errorf("failed to parse event for filtering: %w", err) + } + + // Apply filters with OR semantics for the same event type + for _, filter := range spawner.Filters { + if filter.Event != eventType { + continue + } + + if matchesFilter(filter, eventData) { + return true, nil + } + } + + return false, nil +} + +// matchesFilter checks if event data matches a specific filter. +func matchesFilter(filter v1alpha1.GitHubWebhookFilter, eventData *GitHubEventData) bool { + // Action filter + if filter.Action != "" && filter.Action != eventData.Action { + return false + } + + // Author filter + if filter.Author != "" && filter.Author != eventData.Sender { + return false + } + + // Branch filter (for push events) + if filter.Branch != "" { + if eventData.Branch == "" { + return false + } + matched, _ := filepath.Match(filter.Branch, eventData.Branch) + if !matched { + return false + } + } + + // Event-specific filters + switch e := eventData.RawEvent.(type) { + case *github.IssuesEvent, *github.IssueCommentEvent: + var issue *github.Issue + if issueEvent, ok := e.(*github.IssuesEvent); ok { + issue = issueEvent.GetIssue() + } else if commentEvent, ok := e.(*github.IssueCommentEvent); ok { + issue = commentEvent.GetIssue() + } + + if issue != nil { + // State filter + if filter.State != "" && filter.State != issue.GetState() { + return false + } + + // Labels filter (all required labels must be present) + if len(filter.Labels) > 0 { + issueLabels := make(map[string]bool) + for _, label := range issue.Labels { + issueLabels[label.GetName()] = true + } + for _, requiredLabel := range filter.Labels { + if !issueLabels[requiredLabel] { + return false + } + } + } + } + + // BodyContains filter for comments + if filter.BodyContains != "" { + if commentEvent, ok := e.(*github.IssueCommentEvent); ok { + if comment := commentEvent.GetComment(); comment != nil { + if !strings.Contains(comment.GetBody(), filter.BodyContains) { + return false + } + } + } + } + + case *github.PullRequestEvent, *github.PullRequestReviewEvent, *github.PullRequestReviewCommentEvent: + var pr *github.PullRequest + switch event := e.(type) { + case *github.PullRequestEvent: + pr = event.GetPullRequest() + case *github.PullRequestReviewEvent: + pr = event.GetPullRequest() + case *github.PullRequestReviewCommentEvent: + pr = event.GetPullRequest() + } + + if pr != nil { + // State filter + if filter.State != "" && filter.State != pr.GetState() { + return false + } + + // Draft filter + if filter.Draft != nil && *filter.Draft != pr.GetDraft() { + return false + } + + // Labels filter (all required labels must be present) + if len(filter.Labels) > 0 { + prLabels := make(map[string]bool) + for _, label := range pr.Labels { + prLabels[label.GetName()] = true + } + for _, requiredLabel := range filter.Labels { + if !prLabels[requiredLabel] { + return false + } + } + } + } + + // BodyContains filter for reviews + if filter.BodyContains != "" { + if reviewEvent, ok := e.(*github.PullRequestReviewEvent); ok { + if review := reviewEvent.GetReview(); review != nil { + if !strings.Contains(review.GetBody(), filter.BodyContains) { + return false + } + } + } else if commentEvent, ok := e.(*github.PullRequestReviewCommentEvent); ok { + if comment := commentEvent.GetComment(); comment != nil { + if !strings.Contains(comment.GetBody(), filter.BodyContains) { + return false + } + } + } + } + } + + return true +} + +// ExtractGitHubWorkItem extracts template variables from GitHub webhook events for task creation. +func ExtractGitHubWorkItem(eventData *GitHubEventData) map[string]interface{} { + vars := map[string]interface{}{ + "Event": eventData.Event, + "Action": eventData.Action, + "Sender": eventData.Sender, + "Ref": eventData.Ref, + "Payload": eventData.RawEvent, + // Standard variables for compatibility + "ID": eventData.ID, + "Title": eventData.Title, + "Kind": "webhook", + } + + // Add number, body, URL if available + if eventData.Number > 0 { + vars["Number"] = eventData.Number + } + if eventData.Body != "" { + vars["Body"] = eventData.Body + } + if eventData.URL != "" { + vars["URL"] = eventData.URL + } + if eventData.Branch != "" { + vars["Branch"] = eventData.Branch + } + + return vars +} diff --git a/internal/webhook/github_filter_test.go b/internal/webhook/github_filter_test.go new file mode 100644 index 00000000..fadd7eaf --- /dev/null +++ b/internal/webhook/github_filter_test.go @@ -0,0 +1,462 @@ +package webhook + +import ( + "testing" + + "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestMatchesGitHubEvent_EventTypeFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"issues", "pull_request"}, + } + + tests := []struct { + name string + eventType string + want bool + wantErr bool + }{ + { + name: "allowed event type", + eventType: "issues", + want: true, + }, + { + name: "another allowed event type", + eventType: "pull_request", + want: true, + }, + { + name: "disallowed event type", + eventType: "push", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(`{"action":"opened","sender":{"login":"user"}}`) + got, err := MatchesGitHubEvent(spawner, tt.eventType, payload) + if (err != nil) != tt.wantErr { + t.Errorf("MatchesGitHubEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_ActionFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "issues", + Action: "opened", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matching action", + payload: `{"action":"opened","sender":{"login":"user"}}`, + want: true, + }, + { + name: "non-matching action", + payload: `{"action":"closed","sender":{"login":"user"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "issues", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_AuthorFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "issues", + Author: "specific-user", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matching author", + payload: `{"action":"opened","sender":{"login":"specific-user"}}`, + want: true, + }, + { + name: "non-matching author", + payload: `{"action":"opened","sender":{"login":"other-user"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "issues", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_LabelsFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "issues", + Labels: []string{"bug", "priority:high"}, + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "has all required labels", + payload: `{ + "action":"opened", + "sender":{"login":"user"}, + "issue":{ + "number":1, + "title":"Test issue", + "body":"Test body", + "html_url":"https://github.com/owner/repo/issues/1", + "state":"open", + "labels":[ + {"name":"bug"}, + {"name":"priority:high"}, + {"name":"frontend"} + ] + } + }`, + want: true, + }, + { + name: "missing required label", + payload: `{ + "action":"opened", + "sender":{"login":"user"}, + "issue":{ + "number":1, + "title":"Test issue", + "body":"Test body", + "html_url":"https://github.com/owner/repo/issues/1", + "state":"open", + "labels":[ + {"name":"bug"}, + {"name":"frontend"} + ] + } + }`, + want: false, + }, + { + name: "no labels", + payload: `{ + "action":"opened", + "sender":{"login":"user"}, + "issue":{ + "number":1, + "title":"Test issue", + "body":"Test body", + "html_url":"https://github.com/owner/repo/issues/1", + "state":"open", + "labels":[] + } + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "issues", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_PullRequestDraftFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"pull_request"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "pull_request", + Draft: func() *bool { b := false; return &b }(), // Only ready PRs + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "ready PR", + payload: `{ + "action":"opened", + "sender":{"login":"user"}, + "pull_request":{ + "number":1, + "title":"Test PR", + "body":"Test body", + "html_url":"https://github.com/owner/repo/pull/1", + "state":"open", + "draft":false, + "head":{"ref":"feature-branch"} + } + }`, + want: true, + }, + { + name: "draft PR", + payload: `{ + "action":"opened", + "sender":{"login":"user"}, + "pull_request":{ + "number":1, + "title":"Test PR", + "body":"Test body", + "html_url":"https://github.com/owner/repo/pull/1", + "state":"open", + "draft":true, + "head":{"ref":"feature-branch"} + } + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "pull_request", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_BranchFilter(t *testing.T) { + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"push"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "push", + Branch: "main", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matching branch", + payload: `{ + "ref":"refs/heads/main", + "sender":{"login":"user"}, + "head_commit":{"id":"abc123"} + }`, + want: true, + }, + { + name: "non-matching branch", + payload: `{ + "ref":"refs/heads/feature", + "sender":{"login":"user"}, + "head_commit":{"id":"abc123"} + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "push", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesGitHubEvent_ORSemantics(t *testing.T) { + // Multiple filters for the same event type should use OR semantics + spawner := &v1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Filters: []v1alpha1.GitHubWebhookFilter{ + { + Event: "issues", + Action: "opened", + }, + { + Event: "issues", + Action: "closed", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matches first filter", + payload: `{"action":"opened","sender":{"login":"user"}}`, + want: true, + }, + { + name: "matches second filter", + payload: `{"action":"closed","sender":{"login":"user"}}`, + want: true, + }, + { + name: "matches neither filter", + payload: `{"action":"edited","sender":{"login":"user"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitHubEvent(spawner, "issues", []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesGitHubEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesGitHubEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseGitHubWebhook(t *testing.T) { + tests := []struct { + name string + eventType string + payload string + wantEvent string + wantTitle string + wantErr bool + }{ + { + name: "issues event", + eventType: "issues", + payload: `{ + "action":"opened", + "sender":{"login":"testuser"}, + "issue":{ + "number":42, + "title":"Test Issue", + "body":"This is a test issue", + "html_url":"https://github.com/owner/repo/issues/42", + "state":"open" + } + }`, + wantEvent: "issues", + wantTitle: "Test Issue", + wantErr: false, + }, + { + name: "pull request event", + eventType: "pull_request", + payload: `{ + "action":"opened", + "sender":{"login":"testuser"}, + "pull_request":{ + "number":123, + "title":"Test PR", + "body":"This is a test PR", + "html_url":"https://github.com/owner/repo/pull/123", + "state":"open", + "head":{"ref":"feature-branch"} + } + }`, + wantEvent: "pull_request", + wantTitle: "Test PR", + wantErr: false, + }, + { + name: "invalid JSON", + eventType: "issues", + payload: `{invalid json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseGitHubWebhook(tt.eventType, []byte(tt.payload)) + if (err != nil) != tt.wantErr { + t.Errorf("ParseGitHubWebhook() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got.Event != tt.wantEvent { + t.Errorf("ParseGitHubWebhook() Event = %v, want %v", got.Event, tt.wantEvent) + } + if got.Title != tt.wantTitle { + t.Errorf("ParseGitHubWebhook() Title = %v, want %v", got.Title, tt.wantTitle) + } + } + }) + } +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go new file mode 100644 index 00000000..6e11f418 --- /dev/null +++ b/internal/webhook/handler.go @@ -0,0 +1,389 @@ +package webhook + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/taskbuilder" +) + +// WebhookSource represents the type of webhook source. +type WebhookSource string + +const ( + GitHubSource WebhookSource = "github" + LinearSource WebhookSource = "linear" + + // GitHub webhook headers + GitHubEventHeader = "X-GitHub-Event" + GitHubSignatureHeader = "X-Hub-Signature-256" + GitHubDeliveryHeader = "X-GitHub-Delivery" + + // Linear webhook headers + LinearSignatureHeader = "Linear-Signature" + LinearDeliveryHeader = "Linear-Delivery" +) + +// WebhookHandler handles webhook requests for a specific source type. +type WebhookHandler struct { + client client.Client + source WebhookSource + log logr.Logger + taskBuilder *taskbuilder.TaskBuilder + secret []byte + deliveryCache *DeliveryCache +} + +// DeliveryCache tracks processed webhook deliveries for idempotency. +type DeliveryCache struct { + mu sync.RWMutex + cache map[string]time.Time +} + +// NewDeliveryCache creates a new delivery cache with cleanup. +func NewDeliveryCache() *DeliveryCache { + cache := &DeliveryCache{ + cache: make(map[string]time.Time), + } + + // Clean up expired entries every hour + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + for range ticker.C { + cache.cleanup() + } + }() + + return cache +} + +// IsProcessed checks if a delivery ID has already been processed. +func (d *DeliveryCache) IsProcessed(deliveryID string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + _, exists := d.cache[deliveryID] + return exists +} + +// MarkProcessed marks a delivery ID as processed. +func (d *DeliveryCache) MarkProcessed(deliveryID string) { + d.mu.Lock() + defer d.mu.Unlock() + d.cache[deliveryID] = time.Now() +} + +// cleanup removes entries older than 24 hours. +func (d *DeliveryCache) cleanup() { + d.mu.Lock() + defer d.mu.Unlock() + + cutoff := time.Now().Add(-24 * time.Hour) + for id, timestamp := range d.cache { + if timestamp.Before(cutoff) { + delete(d.cache, id) + } + } +} + +// NewWebhookHandler creates a new webhook handler for the specified source. +func NewWebhookHandler(client client.Client, source WebhookSource, log logr.Logger) (*WebhookHandler, error) { + secret := []byte(os.Getenv("WEBHOOK_SECRET")) + if len(secret) == 0 { + return nil, fmt.Errorf("WEBHOOK_SECRET environment variable is required") + } + + taskBuilder, err := taskbuilder.NewTaskBuilder(client) + if err != nil { + return nil, fmt.Errorf("failed to create task builder: %w", err) + } + + return &WebhookHandler{ + client: client, + source: source, + log: log, + taskBuilder: taskBuilder, + secret: secret, + deliveryCache: NewDeliveryCache(), + }, nil +} + +// ServeHTTP handles webhook HTTP requests. +func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.log.WithValues("method", r.Method, "path", r.URL.Path, "source", h.source) + + // Only accept POST requests + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read the payload + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error(err, "Failed to read request body") + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Extract headers and validate signature + var eventType, signature, deliveryID string + + switch h.source { + case GitHubSource: + eventType = r.Header.Get(GitHubEventHeader) + signature = r.Header.Get(GitHubSignatureHeader) + deliveryID = r.Header.Get(GitHubDeliveryHeader) + + if err := ValidateGitHubSignature(body, signature, h.secret); err != nil { + log.Error(err, "GitHub signature validation failed") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + case LinearSource: + signature = r.Header.Get(LinearSignatureHeader) + deliveryID = r.Header.Get(LinearDeliveryHeader) + eventType = "linear" // Linear doesn't send event type in header + + if err := ValidateLinearSignature(body, signature, h.secret); err != nil { + log.Error(err, "Linear signature validation failed") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + default: + log.Error(fmt.Errorf("unsupported source: %s", h.source), "Unsupported webhook source") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Check for duplicate delivery + if deliveryID != "" && h.deliveryCache.IsProcessed(deliveryID) { + log.Info("Webhook delivery already processed", "deliveryID", deliveryID) + w.WriteHeader(http.StatusOK) + return + } + + // Process the webhook + processed, err := h.processWebhook(ctx, eventType, body, deliveryID) + if err != nil { + log.Error(err, "Failed to process webhook") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Mark as processed if successful + if processed && deliveryID != "" { + h.deliveryCache.MarkProcessed(deliveryID) + } + + w.WriteHeader(http.StatusOK) +} + +// processWebhook processes a validated webhook payload. +func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, payload []byte, deliveryID string) (bool, error) { + log := h.log.WithValues("eventType", eventType, "deliveryID", deliveryID) + + // Get all TaskSpawners that match this source type + spawners, err := h.getMatchingSpawners(ctx) + if err != nil { + return false, fmt.Errorf("failed to get matching spawners: %w", err) + } + + if len(spawners) == 0 { + log.V(1).Info("No matching TaskSpawners found") + return true, nil // Not an error, just nothing to do + } + + tasksCreated := 0 + + for _, spawner := range spawners { + spawnerLog := log.WithValues("spawner", spawner.Name, "namespace", spawner.Namespace) + + // Check if spawner is suspended + if spawner.Spec.Suspend != nil && *spawner.Spec.Suspend { + spawnerLog.V(1).Info("Skipping suspended spawner") + continue + } + + // Check max concurrency + if spawner.Spec.MaxConcurrency != nil && *spawner.Spec.MaxConcurrency > 0 { + activeTasks := spawner.Status.ActiveTasks + if int32(activeTasks) >= *spawner.Spec.MaxConcurrency { + spawnerLog.Info("Max concurrency reached, returning 503", + "activeTasks", activeTasks, + "maxConcurrency", *spawner.Spec.MaxConcurrency) + + // Return 503 with Retry-After header for this spawner + // Note: This approach returns 503 for any spawner that hits limits, + // which may not be ideal if other spawners could still process it. + // A more sophisticated approach would track per-spawner limits. + return false, &MaxConcurrencyError{ + RetryAfter: 60, // Suggest retry after 60 seconds + } + } + } + + // Check if this webhook matches the spawner's filters + matches, err := h.matchesSpawner(spawner, eventType, payload) + if err != nil { + spawnerLog.Error(err, "Failed to check spawner match") + continue + } + + if !matches { + spawnerLog.V(1).Info("Webhook does not match spawner filters") + continue + } + + // Create task for this spawner + err = h.createTask(ctx, spawner, eventType, payload) + if err != nil { + spawnerLog.Error(err, "Failed to create task") + continue + } + + tasksCreated++ + spawnerLog.Info("Created task from webhook") + } + + log.Info("Webhook processing completed", "tasksCreated", tasksCreated) + return tasksCreated > 0, nil +} + +// MaxConcurrencyError represents an error when max concurrency is exceeded. +type MaxConcurrencyError struct { + RetryAfter int +} + +func (e *MaxConcurrencyError) Error() string { + return fmt.Sprintf("max concurrency exceeded, retry after %d seconds", e.RetryAfter) +} + +// getMatchingSpawners returns TaskSpawners that match the webhook source. +func (h *WebhookHandler) getMatchingSpawners(ctx context.Context) ([]*v1alpha1.TaskSpawner, error) { + var spawnerList v1alpha1.TaskSpawnerList + if err := h.client.List(ctx, &spawnerList, &client.ListOptions{}); err != nil { + return nil, err + } + + var matching []*v1alpha1.TaskSpawner + for i := range spawnerList.Items { + spawner := &spawnerList.Items[i] + + switch h.source { + case GitHubSource: + if spawner.Spec.When.GitHubWebhook != nil { + matching = append(matching, spawner) + } + case LinearSource: + if spawner.Spec.When.LinearWebhook != nil { + matching = append(matching, spawner) + } + } + } + + return matching, nil +} + +// matchesSpawner checks if the webhook matches the spawner's configuration. +func (h *WebhookHandler) matchesSpawner(spawner *v1alpha1.TaskSpawner, eventType string, payload []byte) (bool, error) { + switch h.source { + case GitHubSource: + if spawner.Spec.When.GitHubWebhook == nil { + return false, nil + } + return MatchesGitHubEvent(spawner.Spec.When.GitHubWebhook, eventType, payload) + + case LinearSource: + if spawner.Spec.When.LinearWebhook == nil { + return false, nil + } + return MatchesLinearEvent(spawner.Spec.When.LinearWebhook, payload) + + default: + return false, fmt.Errorf("unsupported source: %s", h.source) + } +} + +// createTask creates a new Task from the webhook event. +func (h *WebhookHandler) createTask(ctx context.Context, spawner *v1alpha1.TaskSpawner, eventType string, payload []byte) error { + // Extract template variables based on source + var templateVars map[string]interface{} + var err error + + switch h.source { + case GitHubSource: + eventData, parseErr := ParseGitHubWebhook(eventType, payload) + if parseErr != nil { + return fmt.Errorf("failed to parse GitHub webhook: %w", parseErr) + } + templateVars = ExtractGitHubWorkItem(eventData) + + case LinearSource: + eventData, parseErr := ParseLinearWebhook(payload) + if parseErr != nil { + return fmt.Errorf("failed to parse Linear webhook: %w", parseErr) + } + templateVars = ExtractLinearWorkItem(eventData) + + default: + return fmt.Errorf("unsupported source: %s", h.source) + } + + // Build unique task name from delivery info + taskName := fmt.Sprintf("%s-%s-%s", spawner.Name, eventType, templateVars["ID"]) + if len(taskName) > 63 { + // Kubernetes name length limit + taskName = taskName[:63] + } + + // Create the task + task, err := h.taskBuilder.BuildTask( + taskName, + spawner.Namespace, + &spawner.Spec.TaskTemplate, + templateVars, + ) + if err != nil { + return fmt.Errorf("failed to build task: %w", err) + } + + // Add webhook-specific annotations + if task.Annotations == nil { + task.Annotations = make(map[string]string) + } + task.Annotations["kelos.dev/source-kind"] = "webhook" + task.Annotations["kelos.dev/source-event"] = eventType + task.Annotations["kelos.dev/source-action"] = fmt.Sprintf("%v", templateVars["Action"]) + task.Annotations["kelos.dev/taskspawner"] = spawner.Name + + // Set owner reference + task.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: spawner.APIVersion, + Kind: spawner.Kind, + Name: spawner.Name, + UID: spawner.UID, + }, + } + + if err := h.client.Create(ctx, task); err != nil { + return fmt.Errorf("failed to create task: %w", err) + } + + return nil +} diff --git a/internal/webhook/linear_filter.go b/internal/webhook/linear_filter.go new file mode 100644 index 00000000..622f2253 --- /dev/null +++ b/internal/webhook/linear_filter.go @@ -0,0 +1,218 @@ +package webhook + +import ( + "encoding/json" + "fmt" + + "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// LinearEventData holds parsed Linear event information for template rendering. +type LinearEventData struct { + // Type (e.g., "Issue", "Comment", "Project") + Type string + // Action (e.g., "create", "update", "remove") + Action string + // Raw parsed event payload for template access + RawPayload map[string]interface{} + // Standard template variables for compatibility + ID string + Title string +} + +// ParseLinearWebhook parses a Linear webhook payload using manual JSON parsing. +func ParseLinearWebhook(payload []byte) (*LinearEventData, error) { + var raw map[string]interface{} + if err := json.Unmarshal(payload, &raw); err != nil { + return nil, fmt.Errorf("failed to parse Linear webhook JSON: %w", err) + } + + data := &LinearEventData{ + RawPayload: raw, + } + + // Extract type from payload + if typ, ok := raw["type"].(string); ok { + data.Type = typ + } + + // Extract action from payload + if action, ok := raw["action"].(string); ok { + data.Action = action + } + + // Extract data object for further processing + var dataObj map[string]interface{} + if d, ok := raw["data"].(map[string]interface{}); ok { + dataObj = d + } + + // Extract common fields based on type + if dataObj != nil { + // Extract ID (could be string or number) + if id, ok := dataObj["id"].(string); ok { + data.ID = id + } else if id, ok := dataObj["id"].(float64); ok { + data.ID = fmt.Sprintf("%.0f", id) + } + + // Extract title + if title, ok := dataObj["title"].(string); ok { + data.Title = title + } + } + + return data, nil +} + +// MatchesLinearEvent evaluates whether a Linear webhook event matches the spawner's filters. +func MatchesLinearEvent(spawner *v1alpha1.LinearWebhook, payload []byte) (bool, error) { + // Parse the event for filtering + eventData, err := ParseLinearWebhook(payload) + if err != nil { + return false, fmt.Errorf("failed to parse Linear webhook: %w", err) + } + + // Check if event type is in the allowed list + typeAllowed := false + for _, allowedType := range spawner.Types { + if allowedType == eventData.Type { + typeAllowed = true + break + } + } + if !typeAllowed { + return false, nil + } + + // If no filters, all events of the allowed types match + if len(spawner.Filters) == 0 { + return true, nil + } + + // Apply filters with OR semantics for the same event type + for _, filter := range spawner.Filters { + if filter.Type != eventData.Type { + continue + } + + if matchesLinearFilter(filter, eventData) { + return true, nil + } + } + + return false, nil +} + +// matchesLinearFilter checks if event data matches a specific Linear filter. +func matchesLinearFilter(filter v1alpha1.LinearWebhookFilter, eventData *LinearEventData) bool { + // Action filter + if filter.Action != "" && filter.Action != eventData.Action { + return false + } + + // Get data object for further filtering + var dataObj map[string]interface{} + if d, ok := eventData.RawPayload["data"].(map[string]interface{}); ok { + dataObj = d + } + + if dataObj == nil { + // If no data object and we have state/label filters, this doesn't match + if len(filter.States) > 0 || len(filter.Labels) > 0 || len(filter.ExcludeLabels) > 0 { + return false + } + // Otherwise, it matches (only action filter matters) + return true + } + + // State filter + if len(filter.States) > 0 { + if state, ok := dataObj["state"].(map[string]interface{}); ok { + if stateName, ok := state["name"].(string); ok { + stateMatches := false + for _, allowedState := range filter.States { + if allowedState == stateName { + stateMatches = true + break + } + } + if !stateMatches { + return false + } + } else { + // No state name found, but state filter required + return false + } + } else { + // No state object found, but state filter required + return false + } + } + + // Labels filter (all required labels must be present) + if len(filter.Labels) > 0 { + labels, ok := dataObj["labels"].([]interface{}) + if !ok || labels == nil { + // No labels found, but labels filter required + return false + } + + // Build set of present label names + presentLabels := make(map[string]bool) + for _, label := range labels { + if labelObj, ok := label.(map[string]interface{}); ok { + if labelName, ok := labelObj["name"].(string); ok { + presentLabels[labelName] = true + } + } + } + + // Check all required labels are present + for _, requiredLabel := range filter.Labels { + if !presentLabels[requiredLabel] { + return false + } + } + } + + // ExcludeLabels filter (issue must NOT have any of these labels) + if len(filter.ExcludeLabels) > 0 { + labels, ok := dataObj["labels"].([]interface{}) + if ok && labels != nil { + // Build set of present label names + presentLabels := make(map[string]bool) + for _, label := range labels { + if labelObj, ok := label.(map[string]interface{}); ok { + if labelName, ok := labelObj["name"].(string); ok { + presentLabels[labelName] = true + } + } + } + + // Check that none of the excluded labels are present + for _, excludeLabel := range filter.ExcludeLabels { + if presentLabels[excludeLabel] { + return false + } + } + } + } + + return true +} + +// ExtractLinearWorkItem extracts template variables from Linear webhook events for task creation. +func ExtractLinearWorkItem(eventData *LinearEventData) map[string]interface{} { + vars := map[string]interface{}{ + "Type": eventData.Type, + "Action": eventData.Action, + "Payload": eventData.RawPayload, + // Standard variables for compatibility + "ID": eventData.ID, + "Title": eventData.Title, + "Kind": "webhook", + } + + return vars +} diff --git a/internal/webhook/linear_filter_test.go b/internal/webhook/linear_filter_test.go new file mode 100644 index 00000000..c2ab835e --- /dev/null +++ b/internal/webhook/linear_filter_test.go @@ -0,0 +1,541 @@ +package webhook + +import ( + "testing" + + "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestMatchesLinearEvent_TypeFilter(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue", "Comment"}, + } + + tests := []struct { + name string + eventType string + want bool + wantErr bool + }{ + { + name: "allowed event type", + eventType: "Issue", + want: true, + }, + { + name: "another allowed event type", + eventType: "Comment", + want: true, + }, + { + name: "disallowed event type", + eventType: "Project", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(`{"type":"` + tt.eventType + `","action":"create","data":{"id":"123"}}`) + got, err := MatchesLinearEvent(spawner, payload) + if (err != nil) != tt.wantErr { + t.Errorf("MatchesLinearEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_ActionFilter(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []v1alpha1.LinearWebhookFilter{ + { + Type: "Issue", + Action: "create", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matching action", + payload: `{"type":"Issue","action":"create","data":{"id":"123"}}`, + want: true, + }, + { + name: "non-matching action", + payload: `{"type":"Issue","action":"update","data":{"id":"123"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_StateFilter(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []v1alpha1.LinearWebhookFilter{ + { + Type: "Issue", + States: []string{"Todo", "In Progress"}, + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matching state", + payload: `{ + "type":"Issue", + "action":"update", + "data":{ + "id":"123", + "title":"Test issue", + "state":{"name":"Todo"} + } + }`, + want: true, + }, + { + name: "another matching state", + payload: `{ + "type":"Issue", + "action":"update", + "data":{ + "id":"123", + "title":"Test issue", + "state":{"name":"In Progress"} + } + }`, + want: true, + }, + { + name: "non-matching state", + payload: `{ + "type":"Issue", + "action":"update", + "data":{ + "id":"123", + "title":"Test issue", + "state":{"name":"Done"} + } + }`, + want: false, + }, + { + name: "no state data", + payload: `{ + "type":"Issue", + "action":"update", + "data":{ + "id":"123", + "title":"Test issue" + } + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_LabelsFilter(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []v1alpha1.LinearWebhookFilter{ + { + Type: "Issue", + Labels: []string{"bug", "priority:high"}, + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "has all required labels", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[ + {"name":"bug"}, + {"name":"priority:high"}, + {"name":"frontend"} + ] + } + }`, + want: true, + }, + { + name: "missing required label", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[ + {"name":"bug"}, + {"name":"frontend"} + ] + } + }`, + want: false, + }, + { + name: "no labels", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[] + } + }`, + want: false, + }, + { + name: "labels field missing", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue" + } + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_ExcludeLabelsFilter(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []v1alpha1.LinearWebhookFilter{ + { + Type: "Issue", + ExcludeLabels: []string{"wontfix", "duplicate"}, + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "no excluded labels", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[ + {"name":"bug"}, + {"name":"frontend"} + ] + } + }`, + want: true, + }, + { + name: "has excluded label", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[ + {"name":"bug"}, + {"name":"wontfix"} + ] + } + }`, + want: false, + }, + { + name: "has another excluded label", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[ + {"name":"duplicate"}, + {"name":"frontend"} + ] + } + }`, + want: false, + }, + { + name: "empty labels array", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue", + "labels":[] + } + }`, + want: true, + }, + { + name: "no labels field", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"123", + "title":"Test issue" + } + }`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_ORSemantics(t *testing.T) { + // Multiple filters for the same event type should use OR semantics + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue"}, + Filters: []v1alpha1.LinearWebhookFilter{ + { + Type: "Issue", + Action: "create", + }, + { + Type: "Issue", + Action: "update", + }, + }, + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "matches first filter", + payload: `{"type":"Issue","action":"create","data":{"id":"123"}}`, + want: true, + }, + { + name: "matches second filter", + payload: `{"type":"Issue","action":"update","data":{"id":"123"}}`, + want: true, + }, + { + name: "matches neither filter", + payload: `{"type":"Issue","action":"remove","data":{"id":"123"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesLinearEvent_NoFilters(t *testing.T) { + spawner := &v1alpha1.LinearWebhook{ + Types: []string{"Issue", "Comment"}, + // No filters - should match all allowed types + } + + tests := []struct { + name string + payload string + want bool + }{ + { + name: "allowed type with no filters", + payload: `{"type":"Issue","action":"create","data":{"id":"123"}}`, + want: true, + }, + { + name: "another allowed type with no filters", + payload: `{"type":"Comment","action":"update","data":{"id":"456"}}`, + want: true, + }, + { + name: "disallowed type", + payload: `{"type":"Project","action":"create","data":{"id":"789"}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesLinearEvent(spawner, []byte(tt.payload)) + if err != nil { + t.Errorf("MatchesLinearEvent() error = %v", err) + return + } + if got != tt.want { + t.Errorf("MatchesLinearEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseLinearWebhook(t *testing.T) { + tests := []struct { + name string + payload string + wantType string + wantID string + wantErr bool + }{ + { + name: "issue event", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":"issue-123", + "title":"Test Issue", + "state":{"name":"Todo"} + } + }`, + wantType: "Issue", + wantID: "issue-123", + wantErr: false, + }, + { + name: "comment event", + payload: `{ + "type":"Comment", + "action":"update", + "data":{ + "id":"comment-456", + "body":"Updated comment" + } + }`, + wantType: "Comment", + wantID: "comment-456", + wantErr: false, + }, + { + name: "numeric ID", + payload: `{ + "type":"Issue", + "action":"create", + "data":{ + "id":789, + "title":"Numeric ID Issue" + } + }`, + wantType: "Issue", + wantID: "789", + wantErr: false, + }, + { + name: "invalid JSON", + payload: `{invalid json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseLinearWebhook([]byte(tt.payload)) + if (err != nil) != tt.wantErr { + t.Errorf("ParseLinearWebhook() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got.Type != tt.wantType { + t.Errorf("ParseLinearWebhook() Type = %v, want %v", got.Type, tt.wantType) + } + if got.ID != tt.wantID { + t.Errorf("ParseLinearWebhook() ID = %v, want %v", got.ID, tt.wantID) + } + } + }) + } +} diff --git a/internal/webhook/signature.go b/internal/webhook/signature.go new file mode 100644 index 00000000..23dbd143 --- /dev/null +++ b/internal/webhook/signature.go @@ -0,0 +1,48 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// ValidateGitHubSignature validates a GitHub webhook signature. +// GitHub sends signatures in the format "sha256=". +func ValidateGitHubSignature(payload []byte, signature string, secret []byte) error { + if signature == "" { + return fmt.Errorf("missing signature") + } + + // GitHub signature format: "sha256=" + if !strings.HasPrefix(signature, "sha256=") { + return fmt.Errorf("invalid signature format: expected sha256= prefix") + } + + expectedSig := signature[7:] // Remove "sha256=" prefix + return validateHMACSignature(payload, expectedSig, secret) +} + +// ValidateLinearSignature validates a Linear webhook signature. +// Linear sends signatures as raw HMAC-SHA256 hex digest. +func ValidateLinearSignature(payload []byte, signature string, secret []byte) error { + if signature == "" { + return fmt.Errorf("missing signature") + } + + return validateHMACSignature(payload, signature, secret) +} + +// validateHMACSignature performs HMAC-SHA256 validation against the expected hex digest. +func validateHMACSignature(payload []byte, expectedSig string, secret []byte) error { + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + actualSig := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(actualSig), []byte(expectedSig)) { + return fmt.Errorf("signature verification failed") + } + + return nil +} diff --git a/internal/webhook/signature_test.go b/internal/webhook/signature_test.go new file mode 100644 index 00000000..fa6a079a --- /dev/null +++ b/internal/webhook/signature_test.go @@ -0,0 +1,87 @@ +package webhook + +import ( + "testing" +) + +func TestValidateGitHubSignature(t *testing.T) { + secret := []byte("my-secret-key") + payload := []byte(`{"action":"opened","number":1}`) + + tests := []struct { + name string + signature string + wantErr bool + }{ + { + name: "valid signature", + signature: "sha256=fb463367c1f8d533acc23e11f8d09ff396c4e2ed73668fcf782f221f779e0424", + wantErr: false, + }, + { + name: "invalid signature", + signature: "sha256=invalid", + wantErr: true, + }, + { + name: "missing prefix", + signature: "fb463367c1f8d533acc23e11f8d09ff396c4e2ed73668fcf782f221f779e0424", + wantErr: true, + }, + { + name: "empty signature", + signature: "", + wantErr: true, + }, + { + name: "wrong prefix", + signature: "sha1=fb463367c1f8d533acc23e11f8d09ff396c4e2ed73668fcf782f221f779e0424", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGitHubSignature(payload, tt.signature, 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","data":{"id":"123"}}`) + + tests := []struct { + name string + signature string + wantErr bool + }{ + { + name: "valid signature", + signature: "21a519179a2a5cb3cc9d6d86d2f8850ac21c048c672922d0cd0640438d645941", + wantErr: false, + }, + { + name: "invalid signature", + signature: "invalid", + wantErr: true, + }, + { + name: "empty signature", + signature: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateLinearSignature(payload, tt.signature, secret) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateLinearSignature() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 25c31f902f9679134757cf000e86d081975b8700 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Mon, 30 Mar 2026 12:19:01 +0200 Subject: [PATCH 2/4] fix: build images --- Makefile | 2 +- cmd/kelos-webhook-server/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6da54c58..63488e6d 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 cmd/ghproxy claude-code codex gemini opencode cursor +IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher cmd/kelos-webhook-server cmd/ghproxy 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/cmd/kelos-webhook-server/Dockerfile b/cmd/kelos-webhook-server/Dockerfile index c2a6719f..a88bb450 100644 --- a/cmd/kelos-webhook-server/Dockerfile +++ b/cmd/kelos-webhook-server/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /workspace From ae5442ab870b803646cccaebc43d75af48137987 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Mon, 30 Mar 2026 12:24:42 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Fixed=20Issues=201.=20P1=20-=20MaxConcurren?= =?UTF-8?q?cyError=20HTTP=20Response=20=E2=9C=85=20Fixed=20ServeHTTP=20to?= =?UTF-8?q?=20properly=20handle=20MaxConcurrencyError=20with=20HTTP=20503?= =?UTF-8?q?=20and=20Retry-After=20header=20Previously=20returned=20generic?= =?UTF-8?q?=20HTTP=20500,=20now=20correctly=20returns=20503=20for=20rate?= =?UTF-8?q?=20limiting=202.=20P1=20-=20Empty=20APIVersion/Kind=20in=20Owne?= =?UTF-8?q?r=20References=20=E2=9C=85=20Fixed=20owner=20reference=20creati?= =?UTF-8?q?on=20to=20use=20client.Scheme().ObjectKinds()=20to=20get=20prop?= =?UTF-8?q?er=20GVK=20Added=20missing=20Controller=20and=20BlockOwnerDelet?= =?UTF-8?q?ion=20fields=20for=20proper=20garbage=20collection=20Previously?= =?UTF-8?q?=20had=20empty=20strings,=20now=20has=20correct=20API=20version?= =?UTF-8?q?=20and=20kind=203.=20P1=20-=20TOCTOU=20Race=20Condition=20in=20?= =?UTF-8?q?Idempotency=20=E2=9C=85=20Replaced=20separate=20IsProcessed()/M?= =?UTF-8?q?arkProcessed()=20with=20atomic=20CheckAndMark()=20Eliminated=20?= =?UTF-8?q?race=20window=20where=20two=20requests=20with=20same=20delivery?= =?UTF-8?q?=20ID=20could=20both=20pass=20the=20check=20Now=20uses=20single?= =?UTF-8?q?=20lock=20operation=20for=20check-and-set=204.=20P2=20-=20Inval?= =?UTF-8?q?id=20Kubernetes=20Names=20from=20Truncation=20=E2=9C=85=20Fixed?= =?UTF-8?q?=20task=20name=20generation=20to=20handle=20nil/empty=20ID=20va?= =?UTF-8?q?lues=20safely=20Added=20strings.TrimRight(taskName[:63],=20"-."?= =?UTF-8?q?)=20to=20ensure=20names=20don't=20end=20with=20invalid=20charac?= =?UTF-8?q?ters=20Prevents=20server-side=20validation=20failures=205.=20P2?= =?UTF-8?q?=20-=20BodyContains=20Filter=20Ignored=20for=20IssuesEvent=20?= =?UTF-8?q?=E2=9C=85=20Fixed=20GitHub=20filter=20to=20check=20issue=20body?= =?UTF-8?q?=20for=20BodyContains=20on=20IssuesEvent=20Previously=20only=20?= =?UTF-8?q?checked=20comment=20body=20on=20IssueCommentEvent=20Now=20prope?= =?UTF-8?q?rly=20filters=20by=20issue=20body=20content=20All=20tests=20pas?= =?UTF-8?q?s=20and=20the=20implementation=20builds=20successfully.=20The?= =?UTF-8?q?=20webhook=20system=20now=20properly=20handles=20rate=20limitin?= =?UTF-8?q?g,=20ensures=20atomic=20idempotency=20checks,=20creates=20valid?= =?UTF-8?q?=20owner=20references=20for=20garbage=20collection,=20generates?= =?UTF-8?q?=20valid=20Kubernetes=20resource=20names,=20and=20correctly=20a?= =?UTF-8?q?pplies=20all=20filter=20conditions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webhook/github_filter.go | 10 ++++-- internal/webhook/handler.go | 57 ++++++++++++++++++------------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/internal/webhook/github_filter.go b/internal/webhook/github_filter.go index fc789ec8..df1377db 100644 --- a/internal/webhook/github_filter.go +++ b/internal/webhook/github_filter.go @@ -230,9 +230,15 @@ func matchesFilter(filter v1alpha1.GitHubWebhookFilter, eventData *GitHubEventDa } } - // BodyContains filter for comments + // BodyContains filter if filter.BodyContains != "" { - if commentEvent, ok := e.(*github.IssueCommentEvent); ok { + if issueEvent, ok := e.(*github.IssuesEvent); ok { + if issue := issueEvent.GetIssue(); issue != nil { + if !strings.Contains(issue.GetBody(), filter.BodyContains) { + return false + } + } + } else if commentEvent, ok := e.(*github.IssueCommentEvent); ok { if comment := commentEvent.GetComment(); comment != nil { if !strings.Contains(comment.GetBody(), filter.BodyContains) { return false diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 6e11f418..05e17432 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -2,10 +2,12 @@ package webhook import ( "context" + "errors" "fmt" "io" "net/http" "os" + "strings" "sync" "time" @@ -69,19 +71,16 @@ func NewDeliveryCache() *DeliveryCache { return cache } -// IsProcessed checks if a delivery ID has already been processed. -func (d *DeliveryCache) IsProcessed(deliveryID string) bool { - d.mu.RLock() - defer d.mu.RUnlock() - _, exists := d.cache[deliveryID] - return exists -} - -// MarkProcessed marks a delivery ID as processed. -func (d *DeliveryCache) MarkProcessed(deliveryID string) { +// CheckAndMark atomically checks if a delivery ID was already processed and marks it if not. +// Returns true if already processed, false if this is the first time. +func (d *DeliveryCache) CheckAndMark(deliveryID string) (alreadyProcessed bool) { d.mu.Lock() defer d.mu.Unlock() + if _, exists := d.cache[deliveryID]; exists { + return true + } d.cache[deliveryID] = time.Now() + return false } // cleanup removes entries older than 24 hours. @@ -171,25 +170,26 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Check for duplicate delivery - if deliveryID != "" && h.deliveryCache.IsProcessed(deliveryID) { + if deliveryID != "" && h.deliveryCache.CheckAndMark(deliveryID) { log.Info("Webhook delivery already processed", "deliveryID", deliveryID) w.WriteHeader(http.StatusOK) return } // Process the webhook - processed, err := h.processWebhook(ctx, eventType, body, deliveryID) + _, err = h.processWebhook(ctx, eventType, body, deliveryID) if err != nil { + var concErr *MaxConcurrencyError + if errors.As(err, &concErr) { + w.Header().Set("Retry-After", fmt.Sprintf("%d", concErr.RetryAfter)) + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } log.Error(err, "Failed to process webhook") http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Mark as processed if successful - if processed && deliveryID != "" { - h.deliveryCache.MarkProcessed(deliveryID) - } - w.WriteHeader(http.StatusOK) } @@ -345,10 +345,10 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *v1alpha1.TaskS } // Build unique task name from delivery info - taskName := fmt.Sprintf("%s-%s-%s", spawner.Name, eventType, templateVars["ID"]) + idVal, _ := templateVars["ID"].(string) + taskName := fmt.Sprintf("%s-%s-%s", spawner.Name, eventType, idVal) if len(taskName) > 63 { - // Kubernetes name length limit - taskName = taskName[:63] + taskName = strings.TrimRight(taskName[:63], "-.") } // Create the task @@ -372,12 +372,21 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *v1alpha1.TaskS task.Annotations["kelos.dev/taskspawner"] = spawner.Name // Set owner reference + gvks, _, err := h.client.Scheme().ObjectKinds(spawner) + if err != nil || len(gvks) == 0 { + return fmt.Errorf("failed to get GVK for TaskSpawner: %w", err) + } + gvk := gvks[0] + isController := true + blockOwnerDeletion := true task.OwnerReferences = []metav1.OwnerReference{ { - APIVersion: spawner.APIVersion, - Kind: spawner.Kind, - Name: spawner.Name, - UID: spawner.UID, + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: spawner.Name, + UID: spawner.UID, + Controller: &isController, + BlockOwnerDeletion: &blockOwnerDeletion, }, } From 5607d002d20deecaa78ec73d39f9c0fec74572d8 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Mon, 30 Mar 2026 12:30:10 +0200 Subject: [PATCH 4/4] chore: generate --- examples/helm-values-webhook.yaml | 12 ++++++------ internal/cli/webhook_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/helm-values-webhook.yaml b/examples/helm-values-webhook.yaml index ea5270bf..c525e42c 100644 --- a/examples/helm-values-webhook.yaml +++ b/examples/helm-values-webhook.yaml @@ -6,19 +6,19 @@ webhookServer: # Enable GitHub webhook server github: enabled: true - replicas: 2 # For high availability - secretName: github-webhook-secret # Must contain WEBHOOK_SECRET key + replicas: 2 # For high availability + secretName: github-webhook-secret # Must contain WEBHOOK_SECRET key # Enable Linear webhook server linear: enabled: true replicas: 1 - secretName: linear-webhook-secret # Must contain WEBHOOK_SECRET key + secretName: linear-webhook-secret # Must contain WEBHOOK_SECRET key # Enable ingress for external webhook access ingress: enabled: true - className: "nginx" # Or your ingress class + className: "nginx" # Or your ingress class host: "webhooks.your-domain.com" annotations: cert-manager.io/cluster-issuer: "letsencrypt-prod" @@ -26,9 +26,9 @@ webhookServer: # Use versioned images for production image: - tag: "v1.0.0" # Replace with your desired version + tag: "v1.0.0" # Replace with your desired version pullPolicy: "IfNotPresent" # Enable telemetry for monitoring telemetry: - enabled: true \ No newline at end of file + enabled: true diff --git a/internal/cli/webhook_test.go b/internal/cli/webhook_test.go index 71100b57..0ded005e 100644 --- a/internal/cli/webhook_test.go +++ b/internal/cli/webhook_test.go @@ -101,4 +101,4 @@ func TestRenderChart_WebhookServersDisabled(t *testing.T) { t.Errorf("did not expect rendered chart to contain %q when webhooks disabled", component) } } -} \ No newline at end of file +}