Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ See the [`self-development/` README](self-development/README.md) for the full pi
| Resource | Key Fields | Full Spec |
|----------|-----------|-----------|
| **Task** | `type`, `prompt`, `credentials`, `workspaceRef`, `dependsOn`, `branch` | [Reference](docs/reference.md#task) |
| **Workspace** | `repo`, `ref`, `secretRef` (PAT or GitHub App), `files` | [Reference](docs/reference.md#workspace) |
| **Workspace** | `repo`, `ref`, `secretRef` (PAT or GitHub App), `files`, `setupCommand` | [Reference](docs/reference.md#workspace) |
| **AgentConfig** | `agentsMD`, `plugins`, `mcpServers` | [Reference](docs/reference.md#agentconfig) |
| **TaskSpawner** | `when`, `taskTemplate`, `pollInterval`, `maxConcurrency` | [Reference](docs/reference.md#taskspawner) |

Expand Down
14 changes: 14 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,11 +623,25 @@ type Slack struct {
// +optional
// +kubebuilder:validation:MaxItems=8
Triggers []SlackTrigger `json:"triggers,omitempty"`

// ExcludePatterns rejects messages whose text matches any of the given
// regular expressions. Each entry is checked independently — the message
// is excluded if the text matches ANY entry. Patterns use Go regexp
// syntax (RE2, unanchored). Leading @-mentions are stripped before
// matching so patterns target semantic content. Does NOT apply to
// slash commands.
// +optional
// +kubebuilder:validation:MaxItems=10
// +kubebuilder:validation:items:MinLength=1
// +kubebuilder:validation:items:MaxLength=256
ExcludePatterns []string `json:"excludePatterns,omitempty"`
}

// SlackTrigger defines a regex pattern trigger for Slack messages.
type SlackTrigger struct {
// Pattern is a Go RE2 regex matched against message text (unanchored).
// Leading @-mentions are stripped before matching so patterns target
// semantic content.
// +optional
// +kubebuilder:validation:MaxLength=256
Pattern string `json:"pattern,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ If template rendering fails (e.g., missing key), the raw prompt string is used a
| `spec.remotes[].url` | Git remote URL | Yes (per remote) |
| `spec.files[].path` | Relative file path inside the repository (e.g., `CLAUDE.md`) | Yes (per file) |
| `spec.files[].content` | File content to write | Yes (per file) |
| `spec.setupCommand` | Exec-form command run in `/workspace/repo` after the repo is cloned, the ref is checked out, remotes are configured, and files are written, but before the agent process starts. Runs as the agent UID with all injected env vars; a non-zero exit fails the Task. Use `["sh", "-c", "<script>"]` for shell pipelines (see [Setup Command](#workspace-setup-command) below) | No |

### Workspace Setup Command

Use `spec.setupCommand` to install language dependencies, prime build caches, or run any other prerequisite step that must complete before the agent inspects the codebase. The command follows the same exec-form convention as Kubernetes `container.command` and `lifecycle.postStart.exec.command` — the array is passed directly to `exec` with no shell interpretation.

```yaml
apiVersion: kelos.dev/v1alpha1
kind: Workspace
metadata:
name: node-app-workspace
spec:
repo: https://github.com/your-org/your-repo.git
ref: main
setupCommand: ["sh", "-c", "npm install && npm run build"]
```

Notes:

- Runs after the repo has been cloned and checked out, additional remotes have been added, and any `spec.files` entries have been written.
- Runs before the agent process starts; if it exits non-zero, the agent never runs and the Task fails.
- Executes in `/workspace/repo` as the agent UID (61100), with access to all built-in Kelos env vars and any `Task.spec.podOverrides.env` entries from the Task that references this Workspace.
- The default form is exec-style; for shell pipelines, environment expansion, or multi-step scripts, wrap the command with `["sh", "-c", "<script>"]`.

### Workspace Authentication

Expand Down Expand Up @@ -167,13 +190,20 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g
| `spec.when.githubWebhook.filters[].draft` | Filter PRs by draft status | No |
| `spec.when.githubWebhook.filters[].author` | Filter by the event sender's username | No |
| `spec.when.githubWebhook.filters[].excludeAuthors` | Exclude events sent by any of these usernames | No |
| `spec.when.githubWebhook.filters[].bodyContains` | Filter by substring match on the comment/review body | No |
| `spec.when.githubWebhook.filters[].bodyContains` | **Deprecated.** Filter by case-sensitive substring match on the comment/review body. Use `bodyPattern` instead | No |
| `spec.when.githubWebhook.filters[].bodyPattern` | Require the comment/review body to match a Go re2 regular expression. When combined with `excludeBodyPatterns`, the body must match this pattern AND not match any exclude entry | No |
| `spec.when.githubWebhook.filters[].excludeBodyPatterns` | Exclude events whose comment/review body matches any of these Go re2 regular expressions (OR semantics) | No |
| `spec.when.githubWebhook.filters[].commentOn` | Scope `issue_comment` events to comments posted on a specific subject: `"Issue"` matches plain issues, `"PullRequest"` matches pull requests. Empty matches both. Ignored for other events | No |
| `spec.when.linearWebhook.types` | Linear resource types to listen for (e.g., `"Issue"`, `"Comment"`) | Yes (when using linearWebhook) |
| `spec.when.linearWebhook.filters[].type` | Scope filter to a specific resource type | No |
| `spec.when.linearWebhook.filters[].action` | Filter by webhook action: `create`, `update`, or `remove` | No |
| `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.slack.channels` | Restrict which Slack channels the bot listens in (channel IDs like `"C0123456789"`); when empty, listens in all invited channels | No |
| `spec.when.slack.triggers[].pattern` | RE2 regex matched against message text (unanchored); leading `<@USER_ID>` mentions are stripped before matching; bot mention required unless `mentionOptional` is set; multiple triggers use OR semantics; when empty, every bot mention fires | No |
| `spec.when.slack.triggers[].mentionOptional` | When `true`, fire on pattern match alone without requiring a bot @-mention | No |
| `spec.when.slack.excludePatterns` | RE2 regex patterns that reject messages when any pattern matches (OR semantics); leading `<@USER_ID>` mentions are stripped before matching; does not apply to slash commands | No |
| `spec.when.webhook.source` | Short identifier for the generic webhook source (lowercase alphanumeric with optional hyphens). Determines the URL path (`/webhook/<source>`). 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) |
Expand Down
12 changes: 8 additions & 4 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command {
fmt.Fprintf(os.Stdout, "task/%s created\n", name)

if watch {
return watchTask(ctx, cl, name, ns)
return watchTask(ctx, cl, name, ns, os.Stdout, os.Stderr)
}
return nil
},
Expand Down Expand Up @@ -350,7 +350,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command {
return cmd
}

func watchTask(ctx context.Context, cl client.Client, name, namespace string) error {
func watchTask(ctx context.Context, cl client.Client, name, namespace string, out, errOut io.Writer) error {
var lastPhase kelosv1alpha1.TaskPhase
for {
task := &kelosv1alpha1.Task{}
Expand All @@ -359,12 +359,16 @@ func watchTask(ctx context.Context, cl client.Client, name, namespace string) er
}

if task.Status.Phase != lastPhase {
fmt.Fprintf(os.Stdout, "task/%s %s\n", name, task.Status.Phase)
fmt.Fprintf(out, "task/%s %s\n", name, task.Status.Phase)
lastPhase = task.Status.Phase
}

if task.Status.Phase == kelosv1alpha1.TaskPhaseSucceeded || task.Status.Phase == kelosv1alpha1.TaskPhaseFailed {
switch task.Status.Phase {
case kelosv1alpha1.TaskPhaseSucceeded:
return nil
case kelosv1alpha1.TaskPhaseFailed:
fmt.Fprintf(errOut, "Run 'kelos logs %s' to view agent output, or 'kelos get tasks %s -d' for details.\n", name, name)
return fmt.Errorf("task %s failed", name)
}

time.Sleep(2 * time.Second)
Expand Down
73 changes: 73 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cli

import (
"bytes"
"context"
"strings"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1"
)

func TestWatchTask_SucceededReturnsNil(t *testing.T) {
task := &kelosv1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{
Name: "task-ok",
Namespace: "default",
},
Status: kelosv1alpha1.TaskStatus{
Phase: kelosv1alpha1.TaskPhaseSucceeded,
},
}

cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).WithStatusSubresource(task).Build()
var out, errOut bytes.Buffer

if err := watchTask(context.Background(), cl, "task-ok", "default", &out, &errOut); err != nil {
t.Fatalf("watchTask returned error on Succeeded: %v", err)
}

if got := out.String(); !strings.Contains(got, "task/task-ok Succeeded") {
t.Errorf("stdout = %q, want it to contain phase line", got)
}
if got := errOut.String(); got != "" {
t.Errorf("stderr = %q, want empty on Succeeded", got)
}
}

func TestWatchTask_FailedReturnsError(t *testing.T) {
task := &kelosv1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{
Name: "task-bad",
Namespace: "default",
},
Status: kelosv1alpha1.TaskStatus{
Phase: kelosv1alpha1.TaskPhaseFailed,
},
}

cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).WithStatusSubresource(task).Build()
var out, errOut bytes.Buffer

err := watchTask(context.Background(), cl, "task-bad", "default", &out, &errOut)
if err == nil {
t.Fatal("watchTask returned nil on Failed, want non-nil error")
}
if !strings.Contains(err.Error(), "task task-bad failed") {
t.Errorf("error = %v, want it to contain 'task task-bad failed'", err)
}

if got := out.String(); !strings.Contains(got, "task/task-bad Failed") {
t.Errorf("stdout = %q, want it to contain phase line", got)
}
hint := errOut.String()
if !strings.Contains(hint, "kelos logs task-bad") {
t.Errorf("stderr = %q, want it to suggest 'kelos logs task-bad'", hint)
}
if !strings.Contains(hint, "kelos get tasks task-bad -d") {
t.Errorf("stderr = %q, want it to suggest 'kelos get tasks task-bad -d'", hint)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3612,6 +3612,20 @@ spec:
type: string
maxItems: 64
type: array
excludePatterns:
description: |-
ExcludePatterns rejects messages whose text matches any of the given
regular expressions. Each entry is checked independently — the message
is excluded if the text matches ANY entry. Patterns use Go regexp
syntax (RE2, unanchored). Leading @-mentions are stripped before
matching so patterns target semantic content. Does NOT apply to
slash commands.
items:
maxLength: 256
minLength: 1
type: string
maxItems: 10
type: array
triggers:
description: |-
Triggers define regex patterns that must match the message text.
Expand All @@ -3627,8 +3641,10 @@ spec:
without requiring a bot @-mention.
type: boolean
pattern:
description: Pattern is a Go RE2 regex matched against
message text (unanchored).
description: |-
Pattern is a Go RE2 regex matched against message text (unanchored).
Leading @-mentions are stripped before matching so patterns target
semantic content.
maxLength: 256
type: string
type: object
Expand Down
20 changes: 18 additions & 2 deletions internal/manifests/install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6731,6 +6731,20 @@ spec:
type: string
maxItems: 64
type: array
excludePatterns:
description: |-
ExcludePatterns rejects messages whose text matches any of the given
regular expressions. Each entry is checked independently — the message
is excluded if the text matches ANY entry. Patterns use Go regexp
syntax (RE2, unanchored). Leading @-mentions are stripped before
matching so patterns target semantic content. Does NOT apply to
slash commands.
items:
maxLength: 256
minLength: 1
type: string
maxItems: 10
type: array
triggers:
description: |-
Triggers define regex patterns that must match the message text.
Expand All @@ -6746,8 +6760,10 @@ spec:
without requiring a bot @-mention.
type: boolean
pattern:
description: Pattern is a Go RE2 regex matched against
message text (unanchored).
description: |-
Pattern is a Go RE2 regex matched against message text (unanchored).
Leading @-mentions are stripped before matching so patterns target
semantic content.
maxLength: 256
type: string
type: object
Expand Down
Loading
Loading