Skip to content

Commit 8a9a9be

Browse files
feat: add github webhook events (#34)
* feat: add github webhook events * fix: build images * Fixed Issues 1. P1 - MaxConcurrencyError HTTP Response ✅ Fixed ServeHTTP to properly handle MaxConcurrencyError with HTTP 503 and Retry-After header Previously returned generic HTTP 500, now correctly returns 503 for rate limiting 2. P1 - Empty APIVersion/Kind in Owner References ✅ Fixed owner reference creation to use client.Scheme().ObjectKinds() to get proper GVK Added missing Controller and BlockOwnerDeletion fields for proper garbage collection Previously had empty strings, now has correct API version and kind 3. P1 - TOCTOU Race Condition in Idempotency ✅ Replaced separate IsProcessed()/MarkProcessed() with atomic CheckAndMark() Eliminated race window where two requests with same delivery ID could both pass the check Now uses single lock operation for check-and-set 4. P2 - Invalid Kubernetes Names from Truncation ✅ Fixed task name generation to handle nil/empty ID values safely Added strings.TrimRight(taskName[:63], "-.") to ensure names don't end with invalid characters Prevents server-side validation failures 5. P2 - BodyContains Filter Ignored for IssuesEvent ✅ Fixed GitHub filter to check issue body for BodyContains on IssuesEvent Previously only checked comment body on IssueCommentEvent Now properly filters by issue body content All tests pass and the implementation builds successfully. The webhook system now properly handles rate limiting, ensures atomic idempotency checks, creates valid owner references for garbage collection, generates valid Kubernetes resource names, and correctly applies all filter conditions. * chore: generate * fix: normalize the tasknames
1 parent a8a012c commit 8a9a9be

33 files changed

Lines changed: 4043 additions & 12 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Image configuration
22
REGISTRY ?= ghcr.io/kelos-dev
33
VERSION ?= latest
4-
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher cmd/ghproxy claude-code codex gemini opencode cursor
4+
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher cmd/kelos-webhook-server cmd/ghproxy claude-code codex gemini opencode cursor
55

66
# Version injection for the kelos CLI – only set ldflags when an explicit
77
# version is given so that dev builds fall through to runtime/debug info.

api/v1alpha1/taskspawner_types.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ type When struct {
3636
// Jira discovers issues from a Jira project.
3737
// +optional
3838
Jira *Jira `json:"jira,omitempty"`
39+
40+
// GitHubWebhook triggers task spawning on GitHub webhook events.
41+
// +optional
42+
GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"`
3943
}
4044

4145
// Cron triggers task spawning on a cron schedule.
@@ -295,6 +299,65 @@ type Jira struct {
295299
PollInterval string `json:"pollInterval,omitempty"`
296300
}
297301

302+
// GitHubWebhook configures webhook-driven task spawning from GitHub events.
303+
type GitHubWebhook struct {
304+
// Events is the list of GitHub event types to listen for.
305+
// e.g., "issue_comment", "pull_request_review", "push", "issues"
306+
// +kubebuilder:validation:Required
307+
// +kubebuilder:validation:MinItems=1
308+
Events []string `json:"events"`
309+
310+
// Repository restricts webhooks to a specific repository (owner/repo format).
311+
// If empty, webhooks from any repository are accepted.
312+
// +optional
313+
Repository string `json:"repository,omitempty"`
314+
315+
// Filters refine which events trigger tasks. If multiple filters match
316+
// the same event type, any match triggers a task (OR semantics).
317+
// If empty, all events in the Events list trigger tasks.
318+
// +optional
319+
Filters []GitHubWebhookFilter `json:"filters,omitempty"`
320+
}
321+
322+
// GitHubWebhookFilter defines filtering criteria for GitHub webhook events.
323+
type GitHubWebhookFilter struct {
324+
// Event is the GitHub event type this filter applies to.
325+
// +kubebuilder:validation:Required
326+
Event string `json:"event"`
327+
328+
// Action filters by webhook action (e.g., "created", "opened", "submitted").
329+
// +optional
330+
Action string `json:"action,omitempty"`
331+
332+
// BodyContains filters by substring match on the comment/review body.
333+
// +optional
334+
BodyContains string `json:"bodyContains,omitempty"`
335+
336+
// Labels requires the issue/PR to have all of these labels.
337+
// +optional
338+
Labels []string `json:"labels,omitempty"`
339+
340+
// ExcludeLabels excludes issues/PRs with any of these labels.
341+
// +optional
342+
ExcludeLabels []string `json:"excludeLabels,omitempty"`
343+
344+
// State filters by issue/PR state ("open", "closed").
345+
// +optional
346+
State string `json:"state,omitempty"`
347+
348+
// Branch filters push events by branch name (exact match or glob).
349+
// +optional
350+
Branch string `json:"branch,omitempty"`
351+
352+
// Draft filters PRs by draft status. nil = don't filter.
353+
// +optional
354+
Draft *bool `json:"draft,omitempty"`
355+
356+
// Author filters by the event sender's username.
357+
// +optional
358+
Author string `json:"author,omitempty"`
359+
}
360+
298361
// TaskTemplateMetadata holds optional labels and annotations for spawned Tasks.
299362
type TaskTemplateMetadata struct {
300363
// Labels are merged into the spawned Task's labels. Values support Go
@@ -355,6 +418,7 @@ type TaskTemplate struct {
355418
// Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}}
356419
// GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}}
357420
// GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}}
421+
// GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access)
358422
// Cron sources: {{.Time}}, {{.Schedule}}
359423
// +optional
360424
Branch string `json:"branch,omitempty"`
@@ -363,6 +427,7 @@ type TaskTemplate struct {
363427
// Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}}
364428
// GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}}
365429
// GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}}
430+
// GitHub webhook sources: {{.Event}}, {{.Action}}, {{.Sender}}, {{.Ref}}, {{.Payload}} (full payload access)
366431
// Cron sources: {{.Time}}, {{.Schedule}}
367432
// +optional
368433
PromptTemplate string `json:"promptTemplate,omitempty"`
@@ -396,7 +461,7 @@ type TaskTemplate struct {
396461
}
397462

398463
// TaskSpawnerSpec defines the desired state of TaskSpawner.
399-
// +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"
464+
// +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"
400465
type TaskSpawnerSpec struct {
401466
// When defines the conditions that trigger task spawning.
402467
// +kubebuilder:validation:Required

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM gcr.io/distroless/static:nonroot
2+
WORKDIR /
3+
COPY bin/kelos-webhook-server .
4+
USER 65532:65532
5+
ENTRYPOINT ["/kelos-webhook-server"]

cmd/kelos-webhook-server/main.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"k8s.io/apimachinery/pkg/runtime"
13+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
14+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
15+
ctrl "sigs.k8s.io/controller-runtime"
16+
"sigs.k8s.io/controller-runtime/pkg/healthz"
17+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
18+
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
19+
20+
kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1"
21+
"github.com/kelos-dev/kelos/internal/logging"
22+
"github.com/kelos-dev/kelos/internal/webhook"
23+
)
24+
25+
var (
26+
scheme = runtime.NewScheme()
27+
setupLog = ctrl.Log.WithName("setup")
28+
)
29+
30+
func init() {
31+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
32+
utilruntime.Must(kelosv1alpha1.AddToScheme(scheme))
33+
}
34+
35+
func main() {
36+
var (
37+
source string
38+
metricsAddr string
39+
probeAddr string
40+
webhookAddr string
41+
enableLeaderElection bool
42+
)
43+
44+
flag.StringVar(&source, "source", "", "Webhook source type (github)")
45+
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
46+
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
47+
flag.StringVar(&webhookAddr, "webhook-bind-address", ":8443", "The address the webhook endpoint binds to.")
48+
flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.")
49+
50+
opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine)
51+
flag.Parse()
52+
53+
if err := applyVerbosity(); err != nil {
54+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
55+
os.Exit(1)
56+
}
57+
58+
ctrl.SetLogger(zap.New(zap.UseFlagOptions(opts)))
59+
60+
// Validate source parameter
61+
source = strings.ToLower(strings.TrimSpace(source))
62+
var webhookSource webhook.WebhookSource
63+
switch source {
64+
case "github":
65+
webhookSource = webhook.GitHubSource
66+
default:
67+
setupLog.Error(fmt.Errorf("invalid source: %s", source),
68+
"Source must be 'github'")
69+
os.Exit(1)
70+
}
71+
72+
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
73+
Scheme: scheme,
74+
Metrics: metricsserver.Options{BindAddress: metricsAddr},
75+
HealthProbeBindAddress: probeAddr,
76+
LeaderElection: enableLeaderElection,
77+
LeaderElectionID: fmt.Sprintf("kelos-webhook-%s", source),
78+
})
79+
if err != nil {
80+
setupLog.Error(err, "Unable to start manager")
81+
os.Exit(1)
82+
}
83+
84+
// Set up signal handling context
85+
ctx := ctrl.SetupSignalHandler()
86+
87+
// Create webhook handler
88+
handler, err := webhook.NewWebhookHandler(
89+
ctx,
90+
mgr.GetClient(),
91+
webhookSource,
92+
ctrl.Log.WithName("webhook").WithValues("source", source),
93+
)
94+
if err != nil {
95+
setupLog.Error(err, "Unable to create webhook handler")
96+
os.Exit(1)
97+
}
98+
99+
// Set up HTTP server for webhooks
100+
mux := http.NewServeMux()
101+
mux.Handle("/", handler)
102+
103+
webhookServer := &http.Server{
104+
Addr: webhookAddr,
105+
Handler: mux,
106+
ReadTimeout: 30 * time.Second, // Maximum time to read request including body
107+
WriteTimeout: 30 * time.Second, // Maximum time to write response
108+
ReadHeaderTimeout: 10 * time.Second, // Maximum time to read request headers
109+
IdleTimeout: 120 * time.Second, // Maximum time for keep-alive connections
110+
}
111+
112+
// Start webhook server in goroutine
113+
go func() {
114+
setupLog.Info("Starting webhook server", "addr", webhookAddr, "source", source)
115+
if err := webhookServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
116+
setupLog.Error(err, "Webhook server failed")
117+
os.Exit(1)
118+
}
119+
}()
120+
121+
// Add health checks
122+
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
123+
setupLog.Error(err, "Unable to set up health check")
124+
os.Exit(1)
125+
}
126+
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
127+
setupLog.Error(err, "Unable to set up ready check")
128+
os.Exit(1)
129+
}
130+
131+
setupLog.Info("Starting manager")
132+
133+
// Shutdown webhook server gracefully when context is cancelled
134+
go func() {
135+
<-ctx.Done()
136+
setupLog.Info("Shutting down webhook server")
137+
if err := webhookServer.Shutdown(context.Background()); err != nil {
138+
setupLog.Error(err, "Error shutting down webhook server")
139+
}
140+
}()
141+
142+
if err := mgr.Start(ctx); err != nil {
143+
setupLog.Error(err, "Problem running manager")
144+
os.Exit(1)
145+
}
146+
}

0 commit comments

Comments
 (0)