Skip to content
Merged
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
21 changes: 21 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,23 @@ type Jira struct {
PollInterval string `json:"pollInterval,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
// text/template with the same variables as branch and promptTemplate.
// The kelos.dev/taskspawner label is always set to the TaskSpawner name
// and overrides any user value for that key.
// +optional
Labels map[string]string `json:"labels,omitempty"`

// Annotations are merged into the spawned Task's annotations. Values
// support Go text/template with the same variables as branch and
// promptTemplate. Values from the GitHub source (e.g. kelos.dev/source-kind)
// are applied after rendering and override reserved keys on conflict.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
}

// TaskTemplate defines the template for spawned Tasks.
type TaskTemplate struct {
// Type specifies the agent type (e.g., claude-code).
Expand Down Expand Up @@ -365,6 +382,10 @@ type TaskTemplate struct {
// +optional
PodOverrides *PodOverrides `json:"podOverrides,omitempty"`

// Metadata holds optional labels and annotations for spawned Tasks.
// +optional
Metadata *TaskTemplateMetadata `json:"metadata,omitempty"`

// UpstreamRepo is the upstream repository in "owner/repo" format.
// When set, spawned Tasks inherit this value and inject
// KELOS_UPSTREAM_REPO into the agent container. This is typically
Expand Down
34 changes: 34 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.

68 changes: 62 additions & 6 deletions cmd/kelos-spawner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,25 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa
continue
}

annotations := sourceAnnotations(&ts, item)
renderedLabels, renderedAnnotations, err := renderTaskTemplateMetadata(&ts, item)
if err != nil {
log.Error(err, "Rendering task template metadata", "item", item.ID)
continue
}

labels := make(map[string]string)
for k, v := range renderedLabels {
labels[k] = v
}
labels["kelos.dev/taskspawner"] = ts.Name

annotations := mergeStringMaps(renderedAnnotations, sourceAnnotations(&ts, item))

task := &kelosv1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{
Name: taskName,
Namespace: ts.Namespace,
Labels: map[string]string{
"kelos.dev/taskspawner": ts.Name,
},
Name: taskName,
Namespace: ts.Namespace,
Labels: labels,
Annotations: annotations,
},
Spec: kelosv1alpha1.TaskSpec{
Expand Down Expand Up @@ -426,6 +436,52 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa
return nil
}

// mergeStringMaps returns a new map with keys from base, then keys from overlay
// overwriting on duplicate keys.
func mergeStringMaps(base, overlay map[string]string) map[string]string {
if len(base) == 0 && len(overlay) == 0 {
return nil
}
out := make(map[string]string)
for k, v := range base {
out[k] = v
}
for k, v := range overlay {
out[k] = v
}
return out
}

// renderTaskTemplateMetadata renders taskTemplate.metadata label and annotation
// values using source.RenderTemplate.
func renderTaskTemplateMetadata(ts *kelosv1alpha1.TaskSpawner, item source.WorkItem) (labels map[string]string, annotations map[string]string, err error) {
meta := ts.Spec.TaskTemplate.Metadata
if meta == nil {
return nil, nil, nil
}
if len(meta.Labels) > 0 {
labels = make(map[string]string)
for k, v := range meta.Labels {
rendered, err := source.RenderTemplate(v, item)
if err != nil {
return nil, nil, fmt.Errorf("label %q: %w", k, err)
}
labels[k] = rendered
}
}
if len(meta.Annotations) > 0 {
annotations = make(map[string]string)
for k, v := range meta.Annotations {
rendered, err := source.RenderTemplate(v, item)
if err != nil {
return nil, nil, fmt.Errorf("annotation %q: %w", k, err)
}
annotations[k] = rendered
}
}
return labels, annotations, nil
}

// sourceAnnotations returns annotations that stamp GitHub source metadata
// onto a spawned Task. These annotations enable downstream consumers (such
// as the reporting watcher) to identify the originating issue or PR.
Expand Down
112 changes: 112 additions & 0 deletions cmd/kelos-spawner/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,118 @@ func TestRunCycleWithSource_AnnotationsStamped(t *testing.T) {
}
}

func TestRunCycleWithSource_TaskTemplateMetadataLabelsAndAnnotations(t *testing.T) {
ts := newTaskSpawner("spawner", "default", nil)
ts.Spec.TaskTemplate.Metadata = &kelosv1alpha1.TaskTemplateMetadata{
Labels: map[string]string{
"app": "issue-{{.Number}}",
},
Annotations: map[string]string{
"kelos.dev/custom": "title-{{.Title}}",
},
}
cl, key := setupTest(t, ts)

src := &fakeSource{
items: []source.WorkItem{
{ID: "42", Number: 42, Title: "Hello", Kind: "Issue"},
},
}

if err := runCycleWithSource(context.Background(), cl, key, src); err != nil {
t.Fatalf("Unexpected error: %v", err)
}

var task kelosv1alpha1.Task
if err := cl.Get(context.Background(), types.NamespacedName{Name: "spawner-42", Namespace: "default"}, &task); err != nil {
t.Fatalf("Failed to get created task: %v", err)
}

if task.Labels["app"] != "issue-42" {
t.Errorf(`Labels["app"] = %q, want "issue-42"`, task.Labels["app"])
}
if task.Labels["kelos.dev/taskspawner"] != "spawner" {
t.Errorf(`Labels["kelos.dev/taskspawner"] = %q, want "spawner"`, task.Labels["kelos.dev/taskspawner"])
}
if task.Annotations["kelos.dev/custom"] != "title-Hello" {
t.Errorf(`Annotations["kelos.dev/custom"] = %q, want "title-Hello"`, task.Annotations["kelos.dev/custom"])
}
if task.Annotations[reporting.AnnotationSourceKind] != "issue" {
t.Errorf("Expected source-kind from GitHub source, got %q", task.Annotations[reporting.AnnotationSourceKind])
}
}

func TestRunCycleWithSource_TaskTemplateMetadataReservedAnnotationsPrecedence(t *testing.T) {
ts := newTaskSpawner("spawner", "default", nil)
ts.Spec.TaskTemplate.Metadata = &kelosv1alpha1.TaskTemplateMetadata{
Annotations: map[string]string{
reporting.AnnotationSourceKind: "wrong",
reporting.AnnotationSourceNumber: "999",
reporting.AnnotationGitHubReporting: "disabled",
"kelos.dev/preserved-custom": "from-template",
},
}
ts.Spec.When.GitHubIssues.Reporting = &kelosv1alpha1.GitHubReporting{Enabled: true}
cl, key := setupTest(t, ts)

src := &fakeSource{
items: []source.WorkItem{
{ID: "42", Number: 42, Title: "Test", Kind: "Issue"},
},
}

if err := runCycleWithSource(context.Background(), cl, key, src); err != nil {
t.Fatalf("Unexpected error: %v", err)
}

var task kelosv1alpha1.Task
if err := cl.Get(context.Background(), types.NamespacedName{Name: "spawner-42", Namespace: "default"}, &task); err != nil {
t.Fatalf("Failed to get created task: %v", err)
}

if task.Annotations[reporting.AnnotationSourceKind] != "issue" {
t.Errorf("Source should win for %s, got %q", reporting.AnnotationSourceKind, task.Annotations[reporting.AnnotationSourceKind])
}
if task.Annotations[reporting.AnnotationSourceNumber] != "42" {
t.Errorf("Source should win for %s, got %q", reporting.AnnotationSourceNumber, task.Annotations[reporting.AnnotationSourceNumber])
}
if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" {
t.Errorf("Source should win for %s, got %q", reporting.AnnotationGitHubReporting, task.Annotations[reporting.AnnotationGitHubReporting])
}
if task.Annotations["kelos.dev/preserved-custom"] != "from-template" {
t.Errorf(`Non-conflicting template annotation should be kept, got %q`, task.Annotations["kelos.dev/preserved-custom"])
}
}

func TestRunCycleWithSource_TaskTemplateMetadataTaskSpawnerLabelWins(t *testing.T) {
ts := newTaskSpawner("spawner", "default", nil)
ts.Spec.TaskTemplate.Metadata = &kelosv1alpha1.TaskTemplateMetadata{
Labels: map[string]string{
"kelos.dev/taskspawner": "wrong",
},
}
cl, key := setupTest(t, ts)

src := &fakeSource{
items: []source.WorkItem{
{ID: "1", Title: "Test", Kind: "Issue"},
},
}

if err := runCycleWithSource(context.Background(), cl, key, src); err != nil {
t.Fatalf("Unexpected error: %v", err)
}

var task kelosv1alpha1.Task
if err := cl.Get(context.Background(), types.NamespacedName{Name: "spawner-1", Namespace: "default"}, &task); err != nil {
t.Fatalf("Failed to get created task: %v", err)
}

if task.Labels["kelos.dev/taskspawner"] != "spawner" {
t.Errorf(`Labels["kelos.dev/taskspawner"] = %q, want "spawner"`, task.Labels["kelos.dev/taskspawner"])
}
}

func TestReportingEnabled_IssuesEnabled(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
Expand Down
23 changes: 23 additions & 0 deletions internal/manifests/install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,29 @@ spec:
Custom images must implement the agent image interface
(see docs/agent-image-interface.md).
type: string
metadata:
description: Metadata holds optional labels and annotations for
spawned Tasks.
properties:
annotations:
additionalProperties:
type: string
description: |-
Annotations are merged into the spawned Task's annotations. Values
support Go text/template with the same variables as branch and
promptTemplate. Values from the GitHub source (e.g. kelos.dev/source-kind)
are applied after rendering and override reserved keys on conflict.
type: object
labels:
additionalProperties:
type: string
description: |-
Labels are merged into the spawned Task's labels. Values support Go
text/template with the same variables as branch and promptTemplate.
The kelos.dev/taskspawner label is always set to the TaskSpawner name
and overrides any user value for that key.
type: object
type: object
model:
description: Model optionally overrides the default model.
type: string
Expand Down
Loading