diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index d8dca916..a1f73339 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -337,6 +337,10 @@ type GitHubWebhook struct { // If empty, all events in the Events list trigger tasks. // +optional Filters []GitHubWebhookFilter `json:"filters,omitempty"` + + // Reporting configures status reporting back to the originating GitHub issue or PR. + // +optional + Reporting *GitHubReporting `json:"reporting,omitempty"` } // GitHubWebhookFilter defines filtering criteria for GitHub webhook events. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 731f2836..b4c2b379 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -358,6 +358,11 @@ func (in *GitHubWebhook) DeepCopyInto(out *GitHubWebhook) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Reporting != nil { + in, out := &in.Reporting, &out.Reporting + *out = new(GitHubReporting) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubWebhook. diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index fa9950c1..b9f2d2ec 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -473,7 +473,8 @@ func sourceAnnotations(ts *kelosv1alpha1.TaskSpawner, item source.WorkItem) map[ } // reportingEnabled returns true when GitHub reporting is configured and enabled -// on the TaskSpawner. +// on the TaskSpawner. This only covers polling-based sources (Issues, PRs); +// webhook-based reporting is handled by the webhook server and its handler. func reportingEnabled(ts *kelosv1alpha1.TaskSpawner) bool { if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil { return ts.Spec.When.GitHubIssues.Reporting.Enabled diff --git a/cmd/kelos-webhook-server/main.go b/cmd/kelos-webhook-server/main.go index f25f86c6..691673ae 100644 --- a/cmd/kelos-webhook-server/main.go +++ b/cmd/kelos-webhook-server/main.go @@ -39,6 +39,10 @@ func main() { probeAddr string webhookAddr string enableLeaderElection bool + githubOwner string + githubRepo string + githubTokenFile string + githubAPIBaseURL string ) flag.StringVar(&source, "source", "", "Webhook source type (github or linear)") @@ -46,6 +50,10 @@ func main() { flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.StringVar(&webhookAddr, "webhook-bind-address", ":8443", "The address the webhook endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.StringVar(&githubOwner, "github-owner", "", "GitHub repository owner for reporting.") + flag.StringVar(&githubRepo, "github-repo", "", "GitHub repository name for reporting.") + flag.StringVar(&githubTokenFile, "github-token-file", "", "Path to file containing GitHub token for reporting.") + flag.StringVar(&githubAPIBaseURL, "github-api-base-url", "", "GitHub API base URL for reporting (defaults to https://api.github.com).") opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) flag.Parse() @@ -120,6 +128,24 @@ func main() { } }() + // Set up reporting reconciler when GitHub credentials are provided + if githubOwner != "" && githubRepo != "" { + reportingReconciler := &reportingReconciler{ + Client: mgr.GetClient(), + config: reportingConfig{ + GitHubOwner: githubOwner, + GitHubRepo: githubRepo, + GitHubTokenFile: githubTokenFile, + GitHubAPIBaseURL: githubAPIBaseURL, + }, + } + if err := reportingReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Unable to create reporting controller") + os.Exit(1) + } + setupLog.Info("Reporting controller enabled", "owner", githubOwner, "repo", githubRepo) + } + // Add health checks if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "Unable to set up health check") diff --git a/cmd/kelos-webhook-server/reporting.go b/cmd/kelos-webhook-server/reporting.go new file mode 100644 index 00000000..c04a3cc1 --- /dev/null +++ b/cmd/kelos-webhook-server/reporting.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/reporting" +) + +// reportingConfig holds the configuration for the reporting reconciler. +type reportingConfig struct { + GitHubOwner string + GitHubRepo string + GitHubTokenFile string + GitHubAPIBaseURL string +} + +// reportingReconciler watches Tasks with GitHub reporting annotations +// and reports their status back to GitHub. +type reportingReconciler struct { + client.Client + config reportingConfig +} + +func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.Log.WithName("reporting") + + var task kelosv1alpha1.Task + if err := r.Get(ctx, req.NamespacedName, &task); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Only process tasks with GitHub reporting enabled + if task.Annotations == nil || task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { + return ctrl.Result{}, nil + } + + token, err := readGitHubToken(r.config.GitHubTokenFile) + if err != nil { + return ctrl.Result{}, fmt.Errorf("reading GitHub token for reporting: %w", err) + } + + reporter := &reporting.TaskReporter{ + Client: r.Client, + Reporter: &reporting.GitHubReporter{ + Owner: r.config.GitHubOwner, + Repo: r.config.GitHubRepo, + Token: token, + TokenFile: r.config.GitHubTokenFile, + BaseURL: r.config.GitHubAPIBaseURL, + }, + } + + if err := reporter.ReportTaskStatus(ctx, &task); err != nil { + log.Error(err, "Reporting task status", "task", task.Name) + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *reportingReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("webhook-reporting"). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + For(&kelosv1alpha1.Task{}, builder.WithPredicates( + reportingAnnotationPredicate{}, + )). + Complete(r) +} + +// reportingAnnotationPredicate triggers reconciliation when a Task's status +// phase changes. Status sub-resource updates do not bump metadata.generation, +// so GenerationChangedPredicate alone would miss them. +type reportingAnnotationPredicate struct{} + +func (reportingAnnotationPredicate) Create(_ event.CreateEvent) bool { return true } +func (reportingAnnotationPredicate) Delete(_ event.DeleteEvent) bool { return false } +func (reportingAnnotationPredicate) Generic(_ event.GenericEvent) bool { return false } + +func (reportingAnnotationPredicate) Update(e event.UpdateEvent) bool { + oldTask, ok1 := e.ObjectOld.(*kelosv1alpha1.Task) + newTask, ok2 := e.ObjectNew.(*kelosv1alpha1.Task) + if !ok1 || !ok2 { + return true + } + // Reconcile when the Task phase changes + return oldTask.Status.Phase != newTask.Status.Phase +} + +// readGitHubToken reads the GitHub token from a file or environment variable. +func readGitHubToken(tokenFile string) (string, error) { + token := os.Getenv("GITHUB_TOKEN") + if tokenFile == "" { + return token, nil + } + + data, err := os.ReadFile(tokenFile) + if err != nil { + if os.IsNotExist(err) { + ctrl.Log.WithName("reporting").Info("Token file not yet available, proceeding without token", "path", tokenFile) + return token, nil + } + return "", fmt.Errorf("reading token file: %w", err) + } + + if t := strings.TrimSpace(string(data)); t != "" { + return t, nil + } + return token, nil +} diff --git a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml index 2734ac01..412f33ff 100644 --- a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml @@ -896,6 +896,15 @@ spec: - event type: object type: array + reporting: + description: Reporting configures status reporting back to + the originating GitHub issue or PR. + properties: + enabled: + description: Enabled posts standard status comments back + to the originating GitHub issue or PR. + type: boolean + type: object repository: description: |- Repository restricts webhooks to a specific repository (owner/repo format). diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 841d0c32..bc41d32d 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -1583,6 +1583,15 @@ spec: - event type: object type: array + reporting: + description: Reporting configures status reporting back to + the originating GitHub issue or PR. + properties: + enabled: + description: Enabled posts standard status comments back + to the originating GitHub issue or PR. + type: boolean + type: object repository: description: |- Repository restricts webhooks to a specific repository (owner/repo format). diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 8ae3f220..46b45c5e 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "strconv" "strings" "sync" "time" @@ -16,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/reporting" "github.com/kelos-dev/kelos/internal/taskbuilder" ) @@ -475,9 +477,37 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *v1alpha1.TaskS return fmt.Errorf("failed to build task: %w", err) } + // Stamp reporting annotations for GitHub webhook sources when reporting is enabled. + if h.source == GitHubSource && parsed.GitHub != nil && parsed.GitHub.Number > 0 && + spawner.Spec.When.GitHubWebhook != nil && + spawner.Spec.When.GitHubWebhook.Reporting != nil && + spawner.Spec.When.GitHubWebhook.Reporting.Enabled { + if task.Annotations == nil { + task.Annotations = make(map[string]string) + } + task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" + task.Annotations[reporting.AnnotationSourceKind] = webhookSourceKind(eventType, parsed.GitHub) + task.Annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(parsed.GitHub.Number) + } + if err := h.client.Create(ctx, task); err != nil { return fmt.Errorf("failed to create task: %w", err) } return nil } + +// webhookSourceKind determines the reporting source kind from a GitHub webhook event. +func webhookSourceKind(eventType string, eventData *GitHubEventData) string { + switch eventType { + case "pull_request", "pull_request_review", "pull_request_review_comment", "pull_request_target": + return "pull-request" + case "issue_comment": + if eventData.PullRequestAPIURL != "" { + return "pull-request" + } + return "issue" + default: + return "issue" + } +} diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index 4a6e4707..9b73b413 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -18,6 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/reporting" "github.com/kelos-dev/kelos/internal/taskbuilder" ) @@ -275,6 +276,262 @@ func TestServeHTTP_CreatesTaskForMatchingSpawner(t *testing.T) { } } +func TestServeHTTP_StampsReportingAnnotationsWhenEnabled(t *testing.T) { + spawner := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "reporting-spawner", + Namespace: "default", + UID: "reporting-uid", + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + Reporting: &kelosv1alpha1.GitHubReporting{ + Enabled: true, + }, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: "api-key", + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ + Name: "test-workspace", + }, + PromptTemplate: "{{.Title}}", + }, + }, + } + + handler := newTestHandler(t, spawner) + + payload := []byte(issuesPayload) + sig := signPayload(payload, []byte(testSecret)) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload)) + req.Header.Set(GitHubEventHeader, "issues") + req.Header.Set(GitHubSignatureHeader, sig) + req.Header.Set(GitHubDeliveryHeader, "reporting-delivery") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelosv1alpha1.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + + task := taskList.Items[0] + if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + } + if task.Annotations[reporting.AnnotationSourceKind] != "issue" { + t.Errorf("Expected source-kind 'issue', got %q", task.Annotations[reporting.AnnotationSourceKind]) + } + if task.Annotations[reporting.AnnotationSourceNumber] != "42" { + t.Errorf("Expected source-number '42', got %q", task.Annotations[reporting.AnnotationSourceNumber]) + } +} + +func TestServeHTTP_NoReportingAnnotationsWhenDisabled(t *testing.T) { + spawner := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-reporting-spawner", + Namespace: "default", + UID: "no-reporting-uid", + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"issues"}, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: "api-key", + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ + Name: "test-workspace", + }, + PromptTemplate: "{{.Title}}", + }, + }, + } + + handler := newTestHandler(t, spawner) + + payload := []byte(issuesPayload) + sig := signPayload(payload, []byte(testSecret)) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload)) + req.Header.Set(GitHubEventHeader, "issues") + req.Header.Set(GitHubSignatureHeader, sig) + req.Header.Set(GitHubDeliveryHeader, "no-reporting-delivery") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelosv1alpha1.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + + task := taskList.Items[0] + if _, ok := task.Annotations[reporting.AnnotationGitHubReporting]; ok { + t.Error("Expected no github-reporting annotation when reporting is not enabled") + } +} + +func TestServeHTTP_ReportingAnnotationsPullRequest(t *testing.T) { + spawner := &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pr-reporting-spawner", + Namespace: "default", + UID: "pr-reporting-uid", + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubWebhook: &kelosv1alpha1.GitHubWebhook{ + Events: []string{"pull_request"}, + Reporting: &kelosv1alpha1.GitHubReporting{ + Enabled: true, + }, + }, + }, + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: "api-key", + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ + Name: "test-workspace", + }, + PromptTemplate: "{{.Title}}", + }, + }, + } + + handler := newTestHandler(t, spawner) + + payload := []byte(`{ + "action": "opened", + "sender": {"login": "testuser"}, + "repository": {"full_name": "org/repo", "name": "repo", "owner": {"login": "org"}}, + "pull_request": { + "number": 99, + "title": "Test PR", + "body": "PR body", + "html_url": "https://github.com/org/repo/pull/99", + "state": "open", + "head": {"ref": "feature-branch"} + } + }`) + sig := signPayload(payload, []byte(testSecret)) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload)) + req.Header.Set(GitHubEventHeader, "pull_request") + req.Header.Set(GitHubSignatureHeader, sig) + req.Header.Set(GitHubDeliveryHeader, "pr-reporting-delivery") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelosv1alpha1.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + + task := taskList.Items[0] + if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + } + if task.Annotations[reporting.AnnotationSourceKind] != "pull-request" { + t.Errorf("Expected source-kind 'pull-request', got %q", task.Annotations[reporting.AnnotationSourceKind]) + } + if task.Annotations[reporting.AnnotationSourceNumber] != "99" { + t.Errorf("Expected source-number '99', got %q", task.Annotations[reporting.AnnotationSourceNumber]) + } +} + +func TestWebhookSourceKind(t *testing.T) { + tests := []struct { + name string + eventType string + eventData *GitHubEventData + want string + }{ + { + name: "issues event", + eventType: "issues", + eventData: &GitHubEventData{Event: "issues"}, + want: "issue", + }, + { + name: "pull_request event", + eventType: "pull_request", + eventData: &GitHubEventData{Event: "pull_request"}, + want: "pull-request", + }, + { + name: "pull_request_review event", + eventType: "pull_request_review", + eventData: &GitHubEventData{Event: "pull_request_review"}, + want: "pull-request", + }, + { + name: "issue_comment on issue", + eventType: "issue_comment", + eventData: &GitHubEventData{Event: "issue_comment"}, + want: "issue", + }, + { + name: "issue_comment on PR", + eventType: "issue_comment", + eventData: &GitHubEventData{Event: "issue_comment", PullRequestAPIURL: "https://api.github.com/repos/o/r/pulls/1"}, + want: "pull-request", + }, + { + name: "push event", + eventType: "push", + eventData: &GitHubEventData{Event: "push"}, + want: "issue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webhookSourceKind(tt.eventType, tt.eventData) + if got != tt.want { + t.Errorf("webhookSourceKind(%q) = %q, want %q", tt.eventType, got, tt.want) + } + }) + } +} + func TestServeHTTP_SkipsNonMatchingSpawner(t *testing.T) { // Spawner only listens for pull_request events, not issues spawner := &kelosv1alpha1.TaskSpawner{