From 6052977587dc30c3310afc81961f8c8c8abfb849 Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Thu, 26 Feb 2026 21:44:05 +0000 Subject: [PATCH] Support immediate re-triggering of completed tasks via TriggerComment When TriggerComment is configured and a completed task exists for a discovered work item, the spawner now compares the latest trigger comment timestamp against the task's CompletionTime. If the trigger comment is newer, the completed task is deleted and a new one is created, allowing users to re-trigger work without waiting for TTL cleanup. Changes: - Add TriggerTime field to WorkItem to carry trigger comment timestamp - Add CreatedAt to githubComment struct to parse GitHub API timestamps - Refactor fetchComments to return structured comments (with timestamps) - Add latestTriggerTime helper to find the most recent trigger comment - Update spawner dedup logic to delete and recreate completed tasks when a newer trigger comment is found - Add comprehensive tests for retrigger behavior Fixes #432 Co-Authored-By: Claude Opus 4.6 --- cmd/kelos-spawner/main.go | 33 ++++- cmd/kelos-spawner/main_test.go | 241 +++++++++++++++++++++++++++++++++ internal/source/github.go | 59 ++++++-- internal/source/github_test.go | 202 +++++++++++++++++++++++++++ internal/source/source.go | 8 ++ 5 files changed, 528 insertions(+), 15 deletions(-) diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 4306126..d77555b 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -173,19 +173,44 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa return fmt.Errorf("listing existing Tasks: %w", err) } - existingTasks := make(map[string]bool) + existingTaskMap := make(map[string]*kelosv1alpha1.Task) activeTasks := 0 - for _, t := range existingTaskList.Items { - existingTasks[t.Name] = true + for i := range existingTaskList.Items { + t := &existingTaskList.Items[i] + existingTaskMap[t.Name] = t if t.Status.Phase != kelosv1alpha1.TaskPhaseSucceeded && t.Status.Phase != kelosv1alpha1.TaskPhaseFailed { activeTasks++ } } + // Determine whether the source supports retrigger via TriggerComment. + hasTriggerComment := ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.TriggerComment != "" + var newItems []source.WorkItem for _, item := range items { taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) - if !existingTasks[taskName] { + existing, found := existingTaskMap[taskName] + if !found { + newItems = append(newItems, item) + continue + } + + // Retrigger: when TriggerComment is configured and the existing task + // is completed, check whether a trigger comment was posted after the + // task finished. If so, delete the completed task so a new one can be + // created. Note: if creation is later blocked by maxConcurrency or + // maxTotalTasks, the item will be picked up as new on the next cycle + // since the old task no longer exists. + if hasTriggerComment && !item.TriggerTime.IsZero() && + (existing.Status.Phase == kelosv1alpha1.TaskPhaseSucceeded || existing.Status.Phase == kelosv1alpha1.TaskPhaseFailed) && + existing.Status.CompletionTime != nil && + item.TriggerTime.After(existing.Status.CompletionTime.Time) { + + if err := cl.Delete(ctx, existing); err != nil && !apierrors.IsNotFound(err) { + log.Error(err, "Deleting completed task for retrigger", "task", taskName) + continue + } + log.Info("Deleted completed task for retrigger", "task", taskName) newItems = append(newItems, item) } } diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index bda6f5c..5555e52 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -3,6 +3,7 @@ package main import ( "context" "testing" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -995,3 +996,243 @@ func TestRunCycleWithSource_CommentFieldsPassedToSource(t *testing.T) { t.Errorf("ExcludeComments = %v, want %v", ghSrc.ExcludeComments, []string{"/kelos needs-input"}) } } + +func newCompletedTask(name, namespace, spawnerName string, phase kelosv1alpha1.TaskPhase, completionTime time.Time) kelosv1alpha1.Task { + ct := metav1.NewTime(completionTime) + return kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "kelos.dev/taskspawner": spawnerName, + }, + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "test", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeOAuth, + SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + }, + }, + Status: kelosv1alpha1.TaskStatus{ + Phase: phase, + CompletionTime: &ct, + }, + } +} + +func TestRunCycleWithSource_RetriggerCompletedTask(t *testing.T) { + ts := newTaskSpawner("spawner", "default", nil) + ts.Spec.When.GitHubIssues.TriggerComment = "/kelos pick-up" + + completionTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + triggerTime := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) // after completion + + existingTasks := []kelosv1alpha1.Task{ + newCompletedTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseSucceeded, completionTime), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Title: "Retriggered item", TriggerTime: triggerTime}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The old completed task should be deleted and a new one created + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (old deleted, new created), got %d", len(taskList.Items)) + } + // The new task should not have a completion time (it's freshly created) + if taskList.Items[0].Status.CompletionTime != nil { + t.Error("Expected new task to have no CompletionTime") + } +} + +func TestRunCycleWithSource_RetriggerSkippedWhenTriggerBeforeCompletion(t *testing.T) { + ts := newTaskSpawner("spawner", "default", nil) + ts.Spec.When.GitHubIssues.TriggerComment = "/kelos pick-up" + + completionTime := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) + triggerTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) // before completion + + existingTasks := []kelosv1alpha1.Task{ + newCompletedTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseSucceeded, completionTime), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Title: "Old trigger", TriggerTime: triggerTime}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The completed task should remain and no new task should be created + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (no retrigger), got %d", len(taskList.Items)) + } + if taskList.Items[0].Status.CompletionTime == nil { + t.Error("Expected the original completed task to remain") + } +} + +func TestRunCycleWithSource_RetriggerFailedTask(t *testing.T) { + ts := newTaskSpawner("spawner", "default", nil) + ts.Spec.When.GitHubIssues.TriggerComment = "/kelos pick-up" + + completionTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + triggerTime := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) // after completion + + existingTasks := []kelosv1alpha1.Task{ + newCompletedTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseFailed, completionTime), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Title: "Retriggered after failure", TriggerTime: triggerTime}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The failed task should be deleted and a new one created + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (old deleted, new created), got %d", len(taskList.Items)) + } + if taskList.Items[0].Status.CompletionTime != nil { + t.Error("Expected new task to have no CompletionTime") + } +} + +func TestRunCycleWithSource_RetriggerSkippedWithoutTriggerComment(t *testing.T) { + // When TriggerComment is not configured, retrigger should not happen + ts := newTaskSpawner("spawner", "default", nil) + + completionTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + triggerTime := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) + + existingTasks := []kelosv1alpha1.Task{ + newCompletedTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseSucceeded, completionTime), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Title: "Item with trigger time but no config", TriggerTime: triggerTime}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Completed task should remain — no retrigger without TriggerComment config + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (no retrigger), got %d", len(taskList.Items)) + } + if taskList.Items[0].Status.CompletionTime == nil { + t.Error("Expected the original completed task to remain") + } +} + +func TestRunCycleWithSource_RetriggerSkippedForRunningTask(t *testing.T) { + // Active (running) tasks should never be retriggered + ts := newTaskSpawner("spawner", "default", nil) + ts.Spec.When.GitHubIssues.TriggerComment = "/kelos pick-up" + + existingTasks := []kelosv1alpha1.Task{ + newTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseRunning), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Title: "Item", TriggerTime: time.Now()}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Running task should remain and no new task should be created + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (no retrigger for running), got %d", len(taskList.Items)) + } +} + +func TestRunCycleWithSource_RetriggerRespectsMaxConcurrency(t *testing.T) { + ts := newTaskSpawner("spawner", "default", int32Ptr(1)) + ts.Spec.When.GitHubIssues.TriggerComment = "/kelos pick-up" + + completionTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + triggerTime := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) + + // One running task already at the concurrency limit, plus one completed task to retrigger + existingTasks := []kelosv1alpha1.Task{ + newTask("spawner-running", "default", "spawner", kelosv1alpha1.TaskPhaseRunning), + newCompletedTask("spawner-1", "default", "spawner", kelosv1alpha1.TaskPhaseSucceeded, completionTime), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "running", Title: "Running"}, + {ID: "1", Title: "Retriggered", TriggerTime: triggerTime}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The completed task should be deleted (retrigger), but the new task + // should not be created because maxConcurrency=1 is already reached + // by the running task. + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + // The completed task was deleted during the retrigger phase, but the new + // task was not created because maxConcurrency=1 is already filled by the + // running task. The item will be picked up as new on the next cycle. + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task (running only, retrigger blocked by concurrency), got %d", len(taskList.Items)) + } + if taskList.Items[0].Name != "spawner-running" { + t.Errorf("Expected spawner-running to remain, got %q", taskList.Items[0].Name) + } +} diff --git a/internal/source/github.go b/internal/source/github.go index bd700f7..b5a5419 100644 --- a/internal/source/github.go +++ b/internal/source/github.go @@ -10,6 +10,7 @@ import ( "regexp" "strconv" "strings" + "time" ) const ( @@ -55,7 +56,8 @@ type githubLabel struct { } type githubComment struct { - Body string `json:"body"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` } func (s *GitHubSource) baseURL() string { @@ -90,11 +92,13 @@ func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { labels = append(labels, l.Name) } - comments, err := s.fetchComments(ctx, issue.Number) + rawComments, err := s.fetchComments(ctx, issue.Number) if err != nil { return nil, fmt.Errorf("fetching comments for issue #%d: %w", issue.Number, err) } + comments := concatCommentBodies(rawComments) + if needsCommentFilter && !s.passesCommentFilter(comments) { continue } @@ -104,7 +108,7 @@ func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { kind = "PR" } - items = append(items, WorkItem{ + item := WorkItem{ ID: strconv.Itoa(issue.Number), Number: issue.Number, Title: issue.Title, @@ -113,12 +117,39 @@ func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { Labels: labels, Comments: comments, Kind: kind, - }) + } + + // Record the timestamp of the most recent trigger comment so the + // spawner can retrigger completed tasks when a new trigger arrives. + if s.TriggerComment != "" { + item.TriggerTime = latestTriggerTime(rawComments, s.TriggerComment) + } + + items = append(items, item) } return items, nil } +// latestTriggerTime returns the CreatedAt timestamp of the most recent +// comment whose body contains the trigger command, or the zero time if +// none match. +func latestTriggerTime(comments []githubComment, trigger string) time.Time { + var latest time.Time + for _, c := range comments { + if containsCommand(c.Body, trigger) { + t, err := time.Parse(time.RFC3339, c.CreatedAt) + if err != nil { + continue + } + if t.After(latest) { + latest = t + } + } + } + return latest +} + // passesCommentFilter checks whether an issue's comments satisfy the // comment-based trigger and exclude rules. Comments are expected in the // concatenated format produced by fetchComments (separated by "\n---\n"). @@ -318,12 +349,12 @@ func (s *GitHubSource) fetchIssuesPage(ctx context.Context, pageURL string) ([]g return issues, nextURL, nil } -func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) (string, error) { +func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) ([]githubComment, error) { u := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100", s.baseURL(), s.Owner, s.Repo, issueNumber) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { - return "", fmt.Errorf("creating request: %w", err) + return nil, fmt.Errorf("creating request: %w", err) } if s.Token != "" { @@ -333,20 +364,27 @@ func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) (stri resp, err := s.httpClient().Do(req) if err != nil { - return "", fmt.Errorf("fetching comments: %w", err) + return nil, fmt.Errorf("fetching comments: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) } var comments []githubComment if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { - return "", fmt.Errorf("decoding comments: %w", err) + return nil, fmt.Errorf("decoding comments: %w", err) } + return comments, nil +} + +// concatCommentBodies joins comment bodies into a single string separated by +// "\n---\n", matching the format expected by passesCommentFilter. Bodies are +// truncated at maxCommentBytes to bound memory usage. +func concatCommentBodies(comments []githubComment) string { var parts []string totalBytes := 0 for _, c := range comments { @@ -356,8 +394,7 @@ func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) (stri } parts = append(parts, c.Body) } - - return strings.Join(parts, "\n---\n"), nil + return strings.Join(parts, "\n---\n") } var linkNextRe = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) diff --git a/internal/source/github_test.go b/internal/source/github_test.go index d2079e1..ae58cad 100644 --- a/internal/source/github_test.go +++ b/internal/source/github_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestDiscover(t *testing.T) { @@ -975,3 +976,204 @@ func TestContainsCommand(t *testing.T) { }) } } + +func TestLatestTriggerTime(t *testing.T) { + t1 := "2026-01-01T12:00:00Z" + t2 := "2026-01-02T12:00:00Z" + t3 := "2026-01-03T12:00:00Z" + + tests := []struct { + name string + comments []githubComment + trigger string + want string // expected RFC3339 time or "" for zero + }{ + { + name: "no comments", + comments: nil, + trigger: "/kelos pick-up", + want: "", + }, + { + name: "single matching comment", + comments: []githubComment{ + {Body: "/kelos pick-up", CreatedAt: t1}, + }, + trigger: "/kelos pick-up", + want: t1, + }, + { + name: "multiple matching comments returns latest", + comments: []githubComment{ + {Body: "/kelos pick-up", CreatedAt: t1}, + {Body: "regular comment", CreatedAt: t2}, + {Body: "/kelos pick-up", CreatedAt: t3}, + }, + trigger: "/kelos pick-up", + want: t3, + }, + { + name: "no matching comments", + comments: []githubComment{ + {Body: "regular comment", CreatedAt: t1}, + {Body: "another comment", CreatedAt: t2}, + }, + trigger: "/kelos pick-up", + want: "", + }, + { + name: "invalid timestamp skipped", + comments: []githubComment{ + {Body: "/kelos pick-up", CreatedAt: "not-a-timestamp"}, + {Body: "/kelos pick-up", CreatedAt: t2}, + }, + trigger: "/kelos pick-up", + want: t2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := latestTriggerTime(tt.comments, tt.trigger) + if tt.want == "" { + if !got.IsZero() { + t.Errorf("latestTriggerTime() = %v, want zero time", got) + } + return + } + expected, _ := time.Parse(time.RFC3339, tt.want) + if !got.Equal(expected) { + t.Errorf("latestTriggerTime() = %v, want %v", got, expected) + } + }) + } +} + +func TestDiscoverSetsTriggerTime(t *testing.T) { + triggerTS := "2026-01-15T10:30:00Z" + + issues := []githubIssue{ + {Number: 1, Title: "Triggered", Body: "Body 1", HTMLURL: "https://github.com/o/r/issues/1"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/owner/repo/issues": + json.NewEncoder(w).Encode(issues) + case r.URL.Path == "/repos/owner/repo/issues/1/comments": + json.NewEncoder(w).Encode([]githubComment{ + {Body: "some comment", CreatedAt: "2026-01-10T10:00:00Z"}, + {Body: "/kelos pick-up", CreatedAt: triggerTS}, + }) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + TriggerComment: "/kelos pick-up", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + + expected, _ := time.Parse(time.RFC3339, triggerTS) + if !items[0].TriggerTime.Equal(expected) { + t.Errorf("TriggerTime = %v, want %v", items[0].TriggerTime, expected) + } +} + +func TestDiscoverTriggerTimeSurvivesByteLimit(t *testing.T) { + // An early trigger comment passes the comment filter, then a large + // comment pushes us past maxCommentBytes. A second (newer) trigger + // comment posted after the big comment must still be found by + // latestTriggerTime even though concatCommentBodies truncates it. + earlyTS := "2026-01-10T10:00:00Z" + latestTS := "2026-01-20T10:00:00Z" + + issues := []githubIssue{ + {Number: 1, Title: "Big comments", Body: "Body", HTMLURL: "https://github.com/o/r/issues/1"}, + } + + bigBody := strings.Repeat("x", 70*1024) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/owner/repo/issues": + json.NewEncoder(w).Encode(issues) + case r.URL.Path == "/repos/owner/repo/issues/1/comments": + json.NewEncoder(w).Encode([]githubComment{ + {Body: "/kelos pick-up", CreatedAt: earlyTS}, + {Body: bigBody, CreatedAt: "2026-01-15T10:00:00Z"}, + {Body: "/kelos pick-up", CreatedAt: latestTS}, + }) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + TriggerComment: "/kelos pick-up", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + + expected, _ := time.Parse(time.RFC3339, latestTS) + if !items[0].TriggerTime.Equal(expected) { + t.Errorf("TriggerTime = %v, want %v (trigger after byte-limit should still be found)", items[0].TriggerTime, expected) + } +} + +func TestDiscoverTriggerTimeZeroWithoutTriggerComment(t *testing.T) { + issues := []githubIssue{ + {Number: 1, Title: "No trigger", Body: "Body", HTMLURL: "https://github.com/o/r/issues/1"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/owner/repo/issues": + json.NewEncoder(w).Encode(issues) + case r.URL.Path == "/repos/owner/repo/issues/1/comments": + json.NewEncoder(w).Encode([]githubComment{ + {Body: "some comment", CreatedAt: "2026-01-10T10:00:00Z"}, + }) + } + })) + defer server.Close() + + // No TriggerComment configured + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + + if !items[0].TriggerTime.IsZero() { + t.Errorf("TriggerTime = %v, want zero time when TriggerComment not configured", items[0].TriggerTime) + } +} diff --git a/internal/source/source.go b/internal/source/source.go index 4aa1272..d855d68 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -3,6 +3,7 @@ package source import ( "context" "sort" + "time" ) // WorkItem represents a discovered work item from an external source. @@ -17,6 +18,13 @@ type WorkItem struct { Kind string // "Issue" or "PR" Time string // Cron trigger time (RFC3339) Schedule string // Cron schedule expression + + // TriggerTime is the creation time of the most recent trigger comment + // for this work item. It is only set when a TriggerComment filter is + // configured and a matching comment was found. The spawner uses this + // to retrigger completed tasks when the trigger comment is newer than + // the task's completion time. + TriggerTime time.Time } // Source discovers work items from an external system.