Implement webhook-driven TaskSpawner system#32
Conversation
Adds comprehensive webhook support to enable real-time task creation from
GitHub and Linear events, replacing polling with immediate webhook responses.
## Core Features
**API Extensions:**
- Add GitHubWebhook and LinearWebhook types to TaskSpawner.When
- Comprehensive filtering system (action, author, labels, branch, state, etc.)
- Generated CRD manifests with new webhook fields
**Webhook Processing:**
- HMAC-SHA256 signature validation for GitHub and Linear webhooks
- Type-safe GitHub event parsing using go-github/v66 SDK for complete field coverage
- Manual JSON parsing for Linear webhooks with flexible state/label filters
- OR semantics across filters with case-insensitive author matching
**Task Creation:**
- Shared TaskBuilder supporting both WorkItem and webhook inputs
- Webhook-specific template variables (Event, Action, Sender, Payload.*)
- Idempotency via delivery IDs to prevent duplicate tasks
- Webhook source annotations for auditability
**Webhook Server:**
- Controller-runtime based HTTP server (kelos-webhook-server)
- Per-source-type deployments with --source flag (github/linear)
- MaxConcurrency enforcement with 503 + Retry-After responses
- Health and readiness endpoints with graceful shutdown
**Infrastructure:**
- Complete Helm chart templates with per-source deployments
- Single Ingress with path-based routing (/webhook/github, /webhook/linear)
- Per-provider secret configuration via values.yaml
- Dockerfile and Makefile integration
**Quality Assurance:**
- 100+ unit tests covering all webhook components
- 5 integration tests for end-to-end webhook flow verification
- Complete examples for GitHub and Linear webhook configurations
## Template Variables
Webhook-sourced tasks have access to:
- {{.Event}} - GitHub event type or Linear resource type
- {{.Action}} - Webhook action (created, submitted, etc.)
- {{.Sender}} - Username who triggered the event
- {{.Payload.field.sub}} - Full access to raw webhook payload
This enables TaskSpawners to react to webhook events in real-time instead
of polling APIs, providing much faster response times for AI agent workflows.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Greptile SummaryThis PR introduces a comprehensive webhook-driven Key findings from the review:
Confidence Score: 4/5Hold for the P0 panic and three P1 correctness bugs before merging to production. One P0 panic and three P1 correctness bugs need fixes. Architecture, signature validation, idempotency, Helm infrastructure, and test coverage are solid. Once the panic guard, body size limit, draft-nil check, and Linear label case-sensitivity are addressed, this should be safe to merge. internal/webhook/github_filter.go (panic + draft nil bug), internal/webhook/handler.go (body size limit + defer placement), internal/webhook/linear_filter.go (label case-sensitivity inconsistency) Important Files Changed
Reviews (1): Last reviewed commit: "Implement webhook-driven TaskSpawner sys..." | Re-trigger Greptile |
| if !hasAllLabels(event.PullRequest.Labels, filter.Labels) { | ||
| return false, nil | ||
| } | ||
| } |
There was a problem hiding this comment.
Panic on short commit SHA in push event ID extraction
getString(e.HeadCommit.ID)[:8] will panic with a slice bounds out of range if the commit ID is fewer than 8 characters. While full SHAs are 40 characters, an empty string or any short/synthetic ID (e.g. in tests or forged payloads) will cause an unhandled panic, crashing the request goroutine.
| } | |
| idStr := getString(e.HeadCommit.ID) | |
| if len(idStr) > 8 { | |
| idStr = idStr[:8] | |
| } | |
| vars["ID"] = fmt.Sprintf("push-%s", idStr) |
| if err != nil { | ||
| h.Log.Error(err, "Reading request body") | ||
| http.Error(w, "Failed to read body", http.StatusBadRequest) | ||
| return | ||
| } | ||
| defer r.Body.Close() | ||
|
|
||
| // Validate signature | ||
| if err := h.validateSignature(body, r.Header); err != nil { |
There was a problem hiding this comment.
No HTTP body size limit — memory exhaustion risk
io.ReadAll(r.Body) reads the entire request body into memory without any size restriction. A malicious actor can send an arbitrarily large payload to exhaust memory and crash or degrade the server. Wrap the body with http.MaxBytesReader before reading:
| if err != nil { | |
| h.Log.Error(err, "Reading request body") | |
| http.Error(w, "Failed to read body", http.StatusBadRequest) | |
| return | |
| } | |
| defer r.Body.Close() | |
| // Validate signature | |
| if err := h.validateSignature(body, r.Header); err != nil { | |
| r.Body = http.MaxBytesReader(w, r.Body, 25*1024*1024) // 25 MB limit | |
| body, err := io.ReadAll(r.Body) | |
| if err != nil { | |
| h.Log.Error(err, "Reading request body") | |
| http.Error(w, "Failed to read body", http.StatusBadRequest) | |
| return | |
| } | |
| defer r.Body.Close() |
| } | ||
| } | ||
|
|
||
| // Event-specific filtering | ||
| switch e := data.RawEvent.(type) { | ||
| case *github.IssueCommentEvent: | ||
| return matchesIssueCommentEvent(filter, e) | ||
| case *github.IssuesEvent: | ||
| return matchesIssuesEvent(filter, e) | ||
| case *github.PullRequestEvent: | ||
| return matchesPullRequestEvent(filter, e) |
There was a problem hiding this comment.
Draft filter silently passes when
Draft field is nil
In matchesIssueCommentEvent (and identically in matchesPullRequestEvent and matchesPullRequestReviewEvent), the draft check is:
if event.Issue.Draft != nil && *event.Issue.Draft != *filter.Draft {
return false, nil
}When event.Issue.Draft == nil, the inner condition is skipped and the filter passes. A filter with draft: true would incorrectly match events where draft status is unknown/absent, causing unintended task creation.
The guard should require the field to be present and match:
| } | |
| } | |
| // Event-specific filtering | |
| switch e := data.RawEvent.(type) { | |
| case *github.IssueCommentEvent: | |
| return matchesIssueCommentEvent(filter, e) | |
| case *github.IssuesEvent: | |
| return matchesIssuesEvent(filter, e) | |
| case *github.PullRequestEvent: | |
| return matchesPullRequestEvent(filter, e) | |
| if filter.Draft != nil { | |
| if event.Issue == nil || event.Issue.PullRequestLinks == nil { | |
| return false, nil | |
| } | |
| if event.Issue.Draft == nil || *event.Issue.Draft != *filter.Draft { | |
| return false, nil | |
| } | |
| } |
The same fix should be applied at the equivalent locations in matchesPullRequestEvent and matchesPullRequestReviewEvent.
| } | ||
|
|
||
| if len(f.States) > 0 { | ||
| if p.Data.State == nil { | ||
| return false | ||
| } | ||
| stateMatched := false | ||
| for _, s := range f.States { | ||
| if strings.EqualFold(s, p.Data.State.Name) { | ||
| stateMatched = true | ||
| break | ||
| } | ||
| } | ||
| if !stateMatched { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| if len(f.Labels) > 0 { |
There was a problem hiding this comment.
Inconsistent case sensitivity between
Labels and ExcludeLabels filters
Labels is checked via containsAllLabels, which does an exact case-sensitive map lookup. ExcludeLabels is checked with strings.EqualFold, which is case-insensitive. A filter like labels: ["Bug"] will fail to match a Linear label named "bug", while excludeLabels: ["Bug"] will correctly exclude "bug". Update containsAllLabels to use case-insensitive comparison:
func containsAllLabels(have []string, want []string) bool {
for _, w := range want {
found := false
for _, h := range have {
if strings.EqualFold(h, w) {
found = true
break
}
}
if !found {
return false
}
}
return true
}| if err != nil { | ||
| h.Log.Error(err, "Reading request body") | ||
| http.Error(w, "Failed to read body", http.StatusBadRequest) | ||
| return | ||
| } |
There was a problem hiding this comment.
defer r.Body.Close() registered after potential early return
If io.ReadAll returns an error, execution returns before the defer r.Body.Close() is ever registered, leaving the body unclosed. Move the defer before the read:
| if err != nil { | |
| h.Log.Error(err, "Reading request body") | |
| http.Error(w, "Failed to read body", http.StatusBadRequest) | |
| return | |
| } | |
| defer r.Body.Close() | |
| body, err := io.ReadAll(r.Body) | |
| if err != nil { | |
| h.Log.Error(err, "Reading request body") | |
| http.Error(w, "Failed to read body", http.StatusBadRequest) | |
| return | |
| } |
Adds comprehensive webhook support to enable real-time task creation from GitHub and Linear events, replacing polling with immediate webhook responses.
Core Features
API Extensions:
Webhook Processing:
Task Creation:
Webhook Server:
Infrastructure:
Quality Assurance:
Template Variables
Webhook-sourced tasks have access to:
This enables TaskSpawners to react to webhook events in real-time instead of polling APIs, providing much faster response times for AI agent workflows.
What type of PR is this?
What this PR does / why we need it:
Which issue(s) this PR is related to:
Special notes for your reviewer:
Does this PR introduce a user-facing change?