From 9f140487e8bbc40e58a3eeb655d13db0f2ceb9e6 Mon Sep 17 00:00:00 2001 From: Aslak Knutsen Date: Fri, 27 Mar 2026 09:46:03 +0000 Subject: [PATCH] feat(taskspawner): taskTemplate metadata labels and annotations Add spec.taskTemplate.metadata.labels and annotations with template rendering. Merge rendered annotations then apply GitHub source annotations so reserved keys stay consistent. kelos.dev/taskspawner label always wins. Add tests including reserved annotation precedence and taskspawner label. Made-with: Cursor Signed-off-by: Aslak Knutsen --- api/v1alpha1/taskspawner_types.go | 21 +++++ api/v1alpha1/zz_generated.deepcopy.go | 34 ++++++++ cmd/kelos-spawner/main.go | 68 ++++++++++++++-- cmd/kelos-spawner/main_test.go | 112 ++++++++++++++++++++++++++ internal/manifests/install-crd.yaml | 23 ++++++ 5 files changed, 252 insertions(+), 6 deletions(-) diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index f4dd0e7d..7dc07719 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -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). @@ -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 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 83ace025..536ad3b2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -809,6 +809,11 @@ func (in *TaskTemplate) DeepCopyInto(out *TaskTemplate) { *out = new(PodOverrides) (*in).DeepCopyInto(*out) } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(TaskTemplateMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskTemplate. @@ -821,6 +826,35 @@ func (in *TaskTemplate) DeepCopy() *TaskTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskTemplateMetadata) DeepCopyInto(out *TaskTemplateMetadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskTemplateMetadata. +func (in *TaskTemplateMetadata) DeepCopy() *TaskTemplateMetadata { + if in == nil { + return nil + } + out := new(TaskTemplateMetadata) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *When) DeepCopyInto(out *When) { *out = *in diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 70f45487..4928f3b5 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -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{ @@ -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. diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 5b4cb21f..9a51b2a8 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -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{ diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index af40d00d..96f24feb 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -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