diff --git a/README.md b/README.md index 5bb39eb1..d2f5f8b4 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ See the [full AgentConfig spec](docs/reference.md#agentconfig) for plugins, skil Kelos integrates with external systems in two ways: -**TaskSpawner** — Kelos natively watches external sources and automatically creates Tasks. Supports GitHub Issues, GitHub Pull Requests, GitHub Webhooks, Jira, and Cron schedules. No glue code needed. +**TaskSpawner** — Kelos natively watches external sources and automatically creates Tasks. Supports GitHub Issues, GitHub Pull Requests, GitHub Webhooks, Linear Webhooks, Jira, Cron schedules, and Generic Webhooks (for arbitrary HTTP POST sources like Sentry, Notion, or Slack). No glue code needed. ```yaml spec: diff --git a/docs/integration.md b/docs/integration.md index 13025de3..5d3a4288 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -269,6 +269,84 @@ Then configure a webhook in Linear (Settings → API → Webhooks) pointing to ` **Linear-specific variables:** `{{.Type}}` (resource type), `{{.State}}` (workflow state), `{{.Action}}` (webhook action), `{{.IssueID}}` (parent issue ID for Comment events), `{{.Labels}}`, `{{.Payload}}` (full payload access). +### Generic Webhooks + +React to arbitrary HTTP POST events from any system that can deliver a JSON payload — Sentry, Notion, Slack, Drata, PagerDuty, internal services, or anything else. Unlike the GitHub and Linear webhook sources, the generic webhook source has no built-in knowledge of any particular schema; you describe how to extract fields and what to filter on using JSONPath expressions. + +```yaml +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: sentry-error-responder +spec: + when: + webhook: + source: sentry # URL: /webhook/sentry + fieldMapping: + id: "$.data.event.event_id" # required — used for deduplication and task naming + title: "$.data.event.title" + url: "$.data.url" + level: "$.data.event.level" + filters: + - field: "$.data.event.level" + value: "error" + - field: "$.data.event.platform" + pattern: "^(python|go|node)" + taskTemplate: + type: claude-code + workspaceRef: + name: my-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + promptTemplate: | + A new Sentry error was reported. + + Title: {{.Title}} + Level: {{.level}} + URL: {{.URL}} + + Investigate the stack trace in the payload and open a PR with a fix. + branch: "sentry-{{.ID}}" + maxConcurrency: 3 +``` + +**Setup:** Enable the `generic` source on `kelos-webhook-server` in your Helm values: + +```yaml +# Helm values +webhookServer: + sources: + generic: + enabled: true +``` + +The webhook URL is `https://your-webhook-domain/webhook/` (e.g., `/webhook/sentry`). + +> [!WARNING] +> **The generic webhook endpoint is currently unauthenticated.** The handler does not validate request signatures, so any client that can reach `/webhook/` and matches a registered TaskSpawner can trigger Task creation. Until per-source HMAC validation is implemented (tracked in [#1040](https://github.com/kelos-dev/kelos/issues/1040)), restrict access at the network layer: +> +> - Use a `NetworkPolicy` to limit ingress to known sender CIDRs. +> - Front the endpoint with an Ingress / Gateway that enforces IP allowlisting or mTLS. +> - Avoid exposing the webhook Service as `LoadBalancer` on a public network unless ingress is otherwise restricted. +> +> The `webhookServer.sources.generic.secretName` Helm value is reserved for future HMAC validation; it currently mounts env vars that no code reads. + +**Configuration:** + +- **`source`** *(required)* — short identifier (lowercase alphanumeric with optional hyphens) that determines the URL path (`/webhook/`). +- **`fieldMapping`** *(required)* — map of template variable name → JSONPath expression evaluated against the request body. Each key becomes `{{.Key}}` in `promptTemplate` and `branch`. Lowercase keys `id`, `title`, `body`, and `url` are also exposed under their canonical uppercase aliases (`{{.ID}}`, `{{.Title}}`, `{{.Body}}`, `{{.URL}}`) for compatibility with templates written for the GitHub or Linear sources. The **`id` key is required** — it is used for delivery deduplication and Task naming. Missing fields produce empty strings (no error); only malformed JSONPath expressions fail. +- **`filters[]`** *(optional)* — list of conditions that must ALL match for a delivery to trigger a Task (AND semantics across filters). Each filter has a `field` (JSONPath) and exactly one of: + - `value` — exact string match against the extracted value + - `pattern` — Go [regexp](https://pkg.go.dev/regexp/syntax) match against the extracted value + + When `filters` is empty, every delivery triggers a Task. A filter whose `field` is missing in the payload fails (the delivery is skipped). + +**Generic-webhook variables:** `{{.Kind}}` is always `"GenericWebhook"`, `{{.Payload}}` is the full parsed JSON body (use it for advanced templating like `{{.Payload.data.event.platform}}`), and every key from `fieldMapping` becomes a top-level variable. Standard fields `{{.ID}}`, `{{.Title}}`, `{{.Body}}`, and `{{.URL}}` always exist (empty if not mapped). + +See [example 13](../examples/13-taskspawner-generic-webhook/) for a full setup walkthrough. + ### Cron Run agents on a schedule — dependency updates, code health checks, or periodic maintenance. @@ -300,31 +378,33 @@ spec: All `promptTemplate` and `branch` fields support Go `text/template` syntax. Available variables depend on the source: -| Variable | GitHub Issues | GitHub PRs | GitHub Webhook | Jira | Linear Webhook | Cron | -|----------|--------------|------------|----------------|------|----------------|------| -| `{{.ID}}` | Issue number (string) | PR number (string) | Issue/PR number or commit ID | Issue key (e.g., `ENG-42`) | Linear resource ID | Date-time string | -| `{{.Number}}` | Issue number (int) | PR number (int) | Issue/PR number | `0` | Empty | `0` | -| `{{.Title}}` | Issue title | PR title | Issue/PR title | Issue summary | Resource title | Trigger time (RFC3339) | -| `{{.Body}}` | Issue body | PR body | Issue/PR/comment body | Issue description | Empty | Empty | -| `{{.URL}}` | Issue URL | PR URL | Issue/PR URL | Issue URL | Empty | Empty | -| `{{.Labels}}` | Comma-separated | Comma-separated | Empty | Comma-separated | Comma-separated | Empty | -| `{{.Comments}}` | Issue comments | PR comments | Empty | Issue comments | Empty | Empty | -| `{{.Kind}}` | `"Issue"` | `"PR"` | `"webhook"` | Jira issue type | `"LinearWebhook"` | `"Issue"` | -| `{{.Event}}` | Empty | Empty | Event type (e.g., `"issues"`) | Empty | Empty | Empty | -| `{{.Action}}` | Empty | Empty | Action (e.g., `"opened"`) | Empty | Action (e.g., `"create"`, `"update"`) | Empty | -| `{{.Sender}}` | Empty | Empty | Event sender username | Empty | Empty | Empty | -| `{{.Branch}}` | Empty | PR head branch | PR/push branch | Empty | Empty | Empty | -| `{{.Ref}}` | Empty | Empty | Git ref (e.g., `"refs/heads/main"`) | Empty | Empty | Empty | -| `{{.Repository}}` | Empty | Empty | `owner/repo` format | Empty | Empty | Empty | -| `{{.RepositoryOwner}}` | Empty | Empty | Repository owner login | Empty | Empty | Empty | -| `{{.RepositoryName}}` | Empty | Empty | Repository name only | Empty | Empty | Empty | -| `{{.Payload}}` | Empty | Empty | Full webhook payload | Empty | Full Linear webhook payload | Empty | -| `{{.ReviewState}}` | Empty | `approved` / `changes_requested` | Empty | Empty | Empty | Empty | -| `{{.ReviewComments}}` | Empty | Inline review comments | Empty | Empty | Empty | Empty | -| `{{.Type}}` | Empty | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty | -| `{{.State}}` | Empty | Empty | Empty | Empty | Workflow state (e.g., `"Todo"`, `"In Progress"`) | Empty | -| `{{.IssueID}}` | Empty | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty | -| `{{.Time}}` | Empty | Empty | Empty | Empty | Empty | Trigger time (RFC3339) | +| Variable | GitHub Issues | GitHub PRs | GitHub Webhook | Jira | Linear Webhook | Generic Webhook | Cron | +|----------|--------------|------------|----------------|------|----------------|-----------------|------| +| `{{.ID}}` | Issue number (string) | PR number (string) | Issue/PR number or commit ID | Issue key (e.g., `ENG-42`) | Linear resource ID | Mapped `id` field (required) | Date-time string | +| `{{.Number}}` | Issue number (int) | PR number (int) | Issue/PR number | `0` | Empty | Empty | `0` | +| `{{.Title}}` | Issue title | PR title | Issue/PR title | Issue summary | Resource title | Mapped `title` field (if present) | Trigger time (RFC3339) | +| `{{.Body}}` | Issue body | PR body | Issue/PR/comment body | Issue description | Empty | Mapped `body` field (if present) | Empty | +| `{{.URL}}` | Issue URL | PR URL | Issue/PR URL | Issue URL | Empty | Mapped `url` field (if present) | Empty | +| `{{.Labels}}` | Comma-separated | Comma-separated | Empty | Comma-separated | Comma-separated | Empty | Empty | +| `{{.Comments}}` | Issue comments | PR comments | Empty | Issue comments | Empty | Empty | Empty | +| `{{.Kind}}` | `"Issue"` | `"PR"` | `"webhook"` | Jira issue type | `"LinearWebhook"` | `"GenericWebhook"` | `"Issue"` | +| `{{.Event}}` | Empty | Empty | Event type (e.g., `"issues"`) | Empty | Empty | Empty | Empty | +| `{{.Action}}` | Empty | Empty | Action (e.g., `"opened"`) | Empty | Action (e.g., `"create"`, `"update"`) | Empty | Empty | +| `{{.Sender}}` | Empty | Empty | Event sender username | Empty | Empty | Empty | Empty | +| `{{.Branch}}` | Empty | PR head branch | PR/push branch | Empty | Empty | Empty | Empty | +| `{{.Ref}}` | Empty | Empty | Git ref (e.g., `"refs/heads/main"`) | Empty | Empty | Empty | Empty | +| `{{.Repository}}` | Empty | Empty | `owner/repo` format | Empty | Empty | Empty | Empty | +| `{{.RepositoryOwner}}` | Empty | Empty | Repository owner login | Empty | Empty | Empty | Empty | +| `{{.RepositoryName}}` | Empty | Empty | Repository name only | Empty | Empty | Empty | Empty | +| `{{.Payload}}` | Empty | Empty | Full webhook payload | Empty | Full Linear webhook payload | Full parsed JSON body | Empty | +| `{{.ReviewState}}` | Empty | `approved` / `changes_requested` | Empty | Empty | Empty | Empty | Empty | +| `{{.ReviewComments}}` | Empty | Inline review comments | Empty | Empty | Empty | Empty | Empty | +| `{{.Type}}` | Empty | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty | Empty | +| `{{.State}}` | Empty | Empty | Empty | Empty | Workflow state (e.g., `"Todo"`, `"In Progress"`) | Empty | Empty | +| `{{.IssueID}}` | Empty | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty | Empty | +| `{{.Time}}` | Empty | Empty | Empty | Empty | Empty | Empty | Trigger time (RFC3339) | + +> **Generic Webhook only:** any additional keys you declare in `fieldMapping` are also exposed as top-level variables. For example, `fieldMapping: {severity: "$.level"}` makes `{{.severity}}` available in templates. ## Direct Task Creation: Workflow Integration diff --git a/docs/reference.md b/docs/reference.md index 7ed5b480..2f8cd3d8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -114,7 +114,7 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g | Field | Description | Required | |-------|-------------|----------| -| `spec.taskTemplate.workspaceRef.name` | Workspace resource (repo URL, auth, and clone target for spawned Tasks) | Yes (when using `githubIssues`, `githubPullRequests`, `githubWebhook`, or `linearWebhook`) | +| `spec.taskTemplate.workspaceRef.name` | Workspace resource (repo URL, auth, and clone target for spawned Tasks) | Yes (when using `githubIssues`, `githubPullRequests`, `githubWebhook`, `linearWebhook`, or `webhook`) | | `spec.when.githubIssues.repo` | Override repository to poll for issues (in `owner/repo` format or full URL); defaults to workspace repo URL | No | | `spec.when.githubIssues.labels` | Filter issues by labels | No | | `spec.when.githubIssues.excludeLabels` | Exclude issues with these labels | No | @@ -170,6 +170,11 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g | `spec.when.linearWebhook.filters[].states` | Filter by workflow state names (e.g., `"Todo"`, `"In Progress"`) | No | | `spec.when.linearWebhook.filters[].labels` | Require the issue to have all of these labels | No | | `spec.when.linearWebhook.filters[].excludeLabels` | Exclude issues with any of these labels | No | +| `spec.when.webhook.source` | Short identifier for the generic webhook source (lowercase alphanumeric with optional hyphens). Determines the URL path (`/webhook/`). The endpoint is currently unauthenticated — see [#1040](https://github.com/kelos-dev/kelos/issues/1040) | Yes (when using webhook) | +| `spec.when.webhook.fieldMapping` | Map of template variable name → JSONPath expression evaluated against the request body. Each key becomes a top-level template variable. Lowercase `id`, `title`, `body`, `url` are also exposed as `{{.ID}}`, `{{.Title}}`, `{{.Body}}`, `{{.URL}}`. The `id` key is required (used for delivery deduplication and Task naming) | Yes (when using webhook) | +| `spec.when.webhook.filters[].field` | JSONPath expression selecting the payload field to match | Yes (per filter) | +| `spec.when.webhook.filters[].value` | Require an exact string match against the extracted field value (mutually exclusive with `pattern`) | Conditional | +| `spec.when.webhook.filters[].pattern` | Require a regex match against the extracted field value (mutually exclusive with `value`) | Conditional | | `spec.when.jira.pollInterval` | Per-source poll interval override (e.g., `"30s"`, `"5m"`); takes precedence over `spec.pollInterval` | No | | `spec.when.cron.schedule` | Cron schedule expression (e.g., `"0 * * * *"`) | Yes (when using cron) | | `spec.taskTemplate.type` | Agent type (`claude-code`, `codex`, `gemini`, `opencode`, or `cursor`) | Yes | @@ -193,32 +198,34 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g The `promptTemplate` field uses Go `text/template` syntax. Available variables depend on the source type: -| Variable | Description | GitHub Issues | GitHub Pull Requests | GitHub Webhook | Linear Webhook | Cron | -|----------|-------------|---------------|----------------------|----------------|----------------|------| -| `{{.ID}}` | Unique identifier | Issue/PR number as string (e.g., `"42"`) | Pull request number as string | Issue/PR number or commit ID | Linear resource ID | Date-time string (e.g., `"20260207-0900"`) | -| `{{.Number}}` | Issue or PR number | Issue/PR number (e.g., `42`) | Pull request number | Issue/PR number (when available) | Empty | `0` | -| `{{.Title}}` | Title of the work item | Issue/PR title | Pull request title | Issue/PR title or "Push to <branch>" | Resource title | Trigger time (RFC3339) | -| `{{.Body}}` | Body text | Issue/PR body | Pull request body | Issue/PR/comment body | Empty | Empty | -| `{{.URL}}` | URL to the source item | GitHub HTML URL | GitHub PR URL | Issue/PR HTML URL | Empty | Empty | -| `{{.Labels}}` | Comma-separated labels | Issue/PR labels | Pull request labels | Empty | Issue labels | Empty | -| `{{.Comments}}` | Concatenated comments | Issue/PR comments | PR conversation comments | Empty | Empty | Empty | -| `{{.Kind}}` | Type of work item | `"Issue"` or `"PR"` | `"PR"` | `"webhook"` | `"LinearWebhook"` | `"Issue"` | -| `{{.Event}}` | GitHub event type | Empty | Empty | Event type (e.g., `"issues"`, `"pull_request"`, `"push"`) | Empty | Empty | -| `{{.Action}}` | Webhook action | Empty | Empty | Action (e.g., `"opened"`, `"created"`, `"submitted"`) | Action (e.g., `"create"`, `"update"`, `"remove"`) | Empty | -| `{{.Sender}}` | Event sender username | Empty | Empty | Username of person who triggered the event | Empty | Empty | -| `{{.Branch}}` | Git branch to update | Empty | PR head branch (e.g., `"kelos-task-42"`) | PR source branch or push branch | Empty | Empty | -| `{{.Ref}}` | Git ref | Empty | Empty | Git ref for push events (e.g., `"refs/heads/main"`) | Empty | Empty | -| `{{.Repository}}` | Full repository name | Empty | Empty | Repository in `owner/repo` format | Empty | Empty | -| `{{.RepositoryOwner}}` | Repository owner | Empty | Empty | Repository owner login | Empty | Empty | -| `{{.RepositoryName}}` | Repository name | Empty | Empty | Repository name only | Empty | Empty | -| `{{.Payload}}` | Raw event payload | Empty | Empty | Full parsed GitHub webhook payload | Full parsed Linear webhook payload | Empty | -| `{{.ReviewState}}` | Aggregated review state | Empty | `approved`, `changes_requested`, or empty | Empty | Empty | Empty | -| `{{.ReviewComments}}` | Formatted inline review comments | Empty | Inline PR review comments | Empty | Empty | Empty | -| `{{.Type}}` | Resource type | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty | -| `{{.State}}` | Workflow state | Empty | Empty | Empty | Current state name (e.g., `"Todo"`, `"In Progress"`) | Empty | -| `{{.IssueID}}` | Parent issue ID | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty | -| `{{.Time}}` | Trigger time (RFC3339) | Empty | Empty | Empty | Empty | Cron tick time (e.g., `"2026-02-07T09:00:00Z"`) | -| `{{.Schedule}}` | Cron schedule expression | Empty | Empty | Empty | Empty | Schedule string (e.g., `"0 * * * *"`) | +| Variable | Description | GitHub Issues | GitHub Pull Requests | GitHub Webhook | Linear Webhook | Generic Webhook | Cron | +|----------|-------------|---------------|----------------------|----------------|----------------|-----------------|------| +| `{{.ID}}` | Unique identifier | Issue/PR number as string (e.g., `"42"`) | Pull request number as string | Issue/PR number or commit ID | Linear resource ID | Mapped `id` field (required) | Date-time string (e.g., `"20260207-0900"`) | +| `{{.Number}}` | Issue or PR number | Issue/PR number (e.g., `42`) | Pull request number | Issue/PR number (when available) | Empty | Empty | `0` | +| `{{.Title}}` | Title of the work item | Issue/PR title | Pull request title | Issue/PR title or "Push to <branch>" | Resource title | Mapped `title` field (if present) | Trigger time (RFC3339) | +| `{{.Body}}` | Body text | Issue/PR body | Pull request body | Issue/PR/comment body | Empty | Mapped `body` field (if present) | Empty | +| `{{.URL}}` | URL to the source item | GitHub HTML URL | GitHub PR URL | Issue/PR HTML URL | Empty | Mapped `url` field (if present) | Empty | +| `{{.Labels}}` | Comma-separated labels | Issue/PR labels | Pull request labels | Empty | Issue labels | Empty | Empty | +| `{{.Comments}}` | Concatenated comments | Issue/PR comments | PR conversation comments | Empty | Empty | Empty | Empty | +| `{{.Kind}}` | Type of work item | `"Issue"` or `"PR"` | `"PR"` | `"webhook"` | `"LinearWebhook"` | `"GenericWebhook"` | `"Issue"` | +| `{{.Event}}` | GitHub event type | Empty | Empty | Event type (e.g., `"issues"`, `"pull_request"`, `"push"`) | Empty | Empty | Empty | +| `{{.Action}}` | Webhook action | Empty | Empty | Action (e.g., `"opened"`, `"created"`, `"submitted"`) | Action (e.g., `"create"`, `"update"`, `"remove"`) | Empty | Empty | +| `{{.Sender}}` | Event sender username | Empty | Empty | Username of person who triggered the event | Empty | Empty | Empty | +| `{{.Branch}}` | Git branch to update | Empty | PR head branch (e.g., `"kelos-task-42"`) | PR source branch or push branch | Empty | Empty | Empty | +| `{{.Ref}}` | Git ref | Empty | Empty | Git ref for push events (e.g., `"refs/heads/main"`) | Empty | Empty | Empty | +| `{{.Repository}}` | Full repository name | Empty | Empty | Repository in `owner/repo` format | Empty | Empty | Empty | +| `{{.RepositoryOwner}}` | Repository owner | Empty | Empty | Repository owner login | Empty | Empty | Empty | +| `{{.RepositoryName}}` | Repository name | Empty | Empty | Repository name only | Empty | Empty | Empty | +| `{{.Payload}}` | Raw event payload | Empty | Empty | Full parsed GitHub webhook payload | Full parsed Linear webhook payload | Full parsed JSON body | Empty | +| `{{.ReviewState}}` | Aggregated review state | Empty | `approved`, `changes_requested`, or empty | Empty | Empty | Empty | Empty | +| `{{.ReviewComments}}` | Formatted inline review comments | Empty | Inline PR review comments | Empty | Empty | Empty | Empty | +| `{{.Type}}` | Resource type | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty | Empty | +| `{{.State}}` | Workflow state | Empty | Empty | Empty | Current state name (e.g., `"Todo"`, `"In Progress"`) | Empty | Empty | +| `{{.IssueID}}` | Parent issue ID | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty | Empty | +| `{{.Time}}` | Trigger time (RFC3339) | Empty | Empty | Empty | Empty | Empty | Cron tick time (e.g., `"2026-02-07T09:00:00Z"`) | +| `{{.Schedule}}` | Cron schedule expression | Empty | Empty | Empty | Empty | Empty | Schedule string (e.g., `"0 * * * *"`) | + +> **Generic Webhook only:** any additional keys declared in `spec.when.webhook.fieldMapping` are also exposed as top-level template variables (e.g., `fieldMapping: {severity: "$.level"}` makes `{{.severity}}` available). ## Task Status diff --git a/examples/13-taskspawner-generic-webhook/README.md b/examples/13-taskspawner-generic-webhook/README.md new file mode 100644 index 00000000..69d9fcd4 --- /dev/null +++ b/examples/13-taskspawner-generic-webhook/README.md @@ -0,0 +1,179 @@ +# Generic Webhook TaskSpawner Example + +This example demonstrates how to drive a TaskSpawner from an arbitrary HTTP +POST source — anything that can deliver a JSON payload (Sentry, Notion, +Slack, Drata, PagerDuty, internal services). Unlike the GitHub and Linear +webhook sources, the generic webhook has no built-in knowledge of any +particular schema; you describe how to extract fields and what to filter on +using JSONPath expressions. + +This example wires up Sentry error events: every `error`-level event from a +Python, Go, or Node project triggers a Claude Code Task that investigates +the stack trace and opens a PR with a fix. + +## Prerequisites + +1. **Webhook server**: deploy `kelos-webhook-server` with the generic source enabled +2. **Sender configuration**: a Sentry (or other system's) webhook integration + pointed at `/webhook/sentry` +3. **Network restrictions**: the generic endpoint is currently + unauthenticated — see [Webhook Security](#webhook-security) + +## Setup + +### 1. Enable the generic source + +Enable `webhookServer.sources.generic` in your Helm values: + +```yaml +# Helm values +webhookServer: + sources: + generic: + enabled: true + replicas: 1 +``` + +### 2. Configure the sender + +Point the upstream system at `https://your-webhook-domain/webhook/sentry`. + +For Sentry: Settings → Integrations → Custom Webhook, with the URL above. + +> The endpoint does not currently validate signatures, so the webhook +> integration's secret/signing settings have no effect on Kelos. Restrict +> access at the network layer instead — see +> [Webhook Security](#webhook-security). + +### 3. Deploy the TaskSpawner + +```bash +kubectl apply -f taskspawner.yaml +``` + +## Configuration Details + +### `source` + +Lowercase alphanumeric identifier (with optional hyphens). Determines the +webhook URL path: `/webhook/`. + +Each TaskSpawner declares one `source`; multiple TaskSpawners can share a +source to fan out a single event into different work streams. + +### `fieldMapping` + +A map of template variable name → JSONPath expression evaluated against +the request body. Each key becomes `{{.Key}}` in `promptTemplate` and +`branch`. Lowercase `id`, `title`, `body`, and `url` are also exposed under +their canonical uppercase aliases (`{{.ID}}`, `{{.Title}}`, `{{.Body}}`, +`{{.URL}}`) for compatibility with templates written for the GitHub or +Linear sources. + +The **`id` key is required** — it is used to derive a stable delivery ID +for deduplication and to name the spawned Task. Without it, retries of the +same logical event hash to the same body and may dedupe inconsistently. + +Missing fields in the payload produce empty strings rather than errors, so +optional mappings (like `level` here) do not block Task creation. Malformed +JSONPath expressions surface as errors so that broken specs are visible. + +### `filters` + +A list of conditions that **all** must match for a delivery to trigger a +Task (AND semantics). Each filter has a `field` (JSONPath) and exactly one +of: + +- `value` — exact string match against the extracted field value +- `pattern` — Go [regexp](https://pkg.go.dev/regexp/syntax) against the + extracted field value + +When `filters` is empty, every delivery triggers a Task. A filter whose +`field` is missing in the payload fails (the delivery is skipped). + +## Template Variables + +Generic webhook TaskSpawners have access to: + +- `{{.ID}}` / `{{.id}}` — value of the mapped `id` field (required) +- `{{.Title}}` / `{{.title}}` — mapped `title` field (if present) +- `{{.Body}}` / `{{.body}}` — mapped `body` field (if present) +- `{{.URL}}` / `{{.url}}` — mapped `url` field (if present) +- `{{.Kind}}` — always `"GenericWebhook"` +- `{{.Payload}}` — the full parsed JSON body (use it for advanced + templating: `{{.Payload.data.event.platform}}`) +- Any additional key declared in `fieldMapping` — for example, the + `level` and `platform` keys in this example are available as + `{{.level}}` and `{{.platform}}` + +## Sample Payload + +The example matches Sentry error payloads of this shape: + +```json +{ + "action": "created", + "data": { + "event": { + "event_id": "abc123def456", + "title": "ZeroDivisionError: integer division by zero", + "level": "error", + "platform": "python" + }, + "url": "https://sentry.io/organizations/acme/issues/789/" + } +} +``` + +With the configured `fieldMapping`, the spawned Task gets: + +- `{{.ID}}` = `"abc123def456"` +- `{{.Title}}` = `"ZeroDivisionError: integer division by zero"` +- `{{.URL}}` = `"https://sentry.io/organizations/acme/issues/789/"` +- `{{.level}}` = `"error"` +- `{{.platform}}` = `"python"` + +And both `filters` match (level == "error" and platform matches the +regex), so the Task is created. + +## Webhook Security + +> [!WARNING] +> **The generic webhook endpoint is currently unauthenticated.** The +> handler accepts any POST that targets `/webhook/` and matches a +> registered TaskSpawner — request signatures are not validated. Per-source +> HMAC validation is tracked in +> [#1040](https://github.com/kelos-dev/kelos/issues/1040). + +Until that lands, restrict access at the network layer: + +- Use a `NetworkPolicy` to allow ingress only from known sender CIDRs + (Sentry publishes its egress IP ranges). +- Front the endpoint with an Ingress / Gateway that enforces IP allowlisting + or mTLS. +- Keep the webhook Service on a private network and avoid `LoadBalancer` + exposure on the public internet unless ingress is otherwise restricted. + +The Helm chart's `webhookServer.sources.generic.secretName` field is +reserved for future HMAC validation; it currently mounts env vars that +no code reads. + +## Troubleshooting + +- **Tasks not being created** — check the webhook server logs for + request errors or filter mismatches. +- **`fieldMapping must include an 'id' key`** — the CRD enforces an `id` + key in `fieldMapping`. Add one whose JSONPath produces a stable, + unique identifier per logical event. +- **Same event triggering twice** — verify your `id` mapping resolves to + a stable string. Falling back to body hashing means JSON encoding + differences (key order, whitespace) defeat dedup. +- **Filter never matches** — if the field in `filter.field` is missing + from the payload, the filter fails (silent skip). Use `{{.Payload}}` + in a debug template to see the actual structure. + +## Cleanup + +```bash +kubectl delete -f taskspawner.yaml +``` diff --git a/examples/13-taskspawner-generic-webhook/taskspawner.yaml b/examples/13-taskspawner-generic-webhook/taskspawner.yaml new file mode 100644 index 00000000..bfdc3ff1 --- /dev/null +++ b/examples/13-taskspawner-generic-webhook/taskspawner.yaml @@ -0,0 +1,57 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: sentry-error-responder + namespace: default +spec: + # Respond to arbitrary HTTP POST events delivered to /webhook/sentry. + # NOTE: the generic webhook endpoint does not currently validate + # request signatures — restrict ingress at the network layer. See + # https://github.com/kelos-dev/kelos/issues/1040. + when: + webhook: + # Short identifier — determines the URL path (/webhook/). + source: sentry + + # JSONPath → template variable. The "id" key is required and is + # used for delivery deduplication and Task naming. Lowercase id, + # title, body, and url are also exposed under {{.ID}}, {{.Title}}, + # {{.Body}}, and {{.URL}} for compatibility with templates written + # for the GitHub or Linear sources. + fieldMapping: + id: "$.data.event.event_id" + title: "$.data.event.title" + url: "$.data.url" + level: "$.data.event.level" + platform: "$.data.event.platform" + + # Filters use AND semantics — every filter must match for a delivery + # to trigger a Task. Each filter takes exactly one of `value` + # (exact-string match) or `pattern` (regex match). + filters: + - field: "$.data.event.level" + value: "error" + - field: "$.data.event.platform" + pattern: "^(python|go|node)" + + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + branch: "sentry-{{.ID}}" + promptTemplate: | + A new Sentry error was reported. + + Title: {{.Title}} + Level: {{.level}} + Platform: {{.platform}} + URL: {{.URL}} + + Investigate the stack trace in the payload and open a PR with a fix. + + maxConcurrency: 3 + ttlSecondsAfterFinished: 3600 diff --git a/examples/README.md b/examples/README.md index de5869e9..c7e2ccd0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,8 @@ Ready-to-use patterns and YAML manifests for orchestrating AI agents with Kelos. | [09-bedrock-credentials](09-bedrock-credentials/) | Run an agent using AWS Bedrock with static credentials or IRSA | | [10-taskspawner-github-webhook](10-taskspawner-github-webhook/) | Respond to GitHub webhook events (issues, PRs, pushes) in real time | | [11-taskspawner-linear-webhook](11-taskspawner-linear-webhook/) | Respond to Linear webhook events (issues, comments) in real time | +| [12-taskspawner-file-patterns](12-taskspawner-file-patterns/) | Filter GitHub PR / push webhooks by changed-file glob patterns | +| [13-taskspawner-generic-webhook](13-taskspawner-generic-webhook/) | Respond to arbitrary HTTP POST events (Sentry, Notion, Slack, etc.) using JSONPath field mapping and filters | ## How to Use