diff --git a/cmd/complete.go b/cmd/complete.go index a016dc8..2922e49 100644 --- a/cmd/complete.go +++ b/cmd/complete.go @@ -5,7 +5,9 @@ import ( "strings" "github.com/newhook/co/internal/beads" + "github.com/newhook/co/internal/control" "github.com/newhook/co/internal/feedback" + "github.com/newhook/co/internal/github" "github.com/newhook/co/internal/project" "github.com/spf13/cobra" ) @@ -111,6 +113,16 @@ func runComplete(cmd *cobra.Command, args []string) error { } else { fmt.Println("PR feedback polling scheduled") } + + // Spawn workflow watchers immediately to catch fast CI runs + // This avoids the race condition where CI completes before the first feedback poll + ghClient := github.NewClient() + watcherCount, err := control.SpawnWorkflowWatchers(ctx, proj, ghClient, workID, flagCompletePRURL) + if err != nil { + fmt.Printf("Warning: failed to spawn workflow watchers: %v\n", err) + } else if watcherCount > 0 { + fmt.Printf("Spawned %d workflow watcher(s)\n", watcherCount) + } } } diff --git a/internal/control/handler_watch_run.go b/internal/control/handler_watch_run.go index ce82395..532ab1d 100644 --- a/internal/control/handler_watch_run.go +++ b/internal/control/handler_watch_run.go @@ -9,6 +9,7 @@ import ( "time" "github.com/newhook/co/internal/db" + "github.com/newhook/co/internal/github" "github.com/newhook/co/internal/logging" "github.com/newhook/co/internal/project" ) @@ -108,3 +109,49 @@ func ScheduleWatchWorkflowRun(ctx context.Context, proj *project.Project, workID return nil } + +// SpawnWorkflowWatchers checks for in-progress workflow runs and spawns watchers for them. +// This can be called immediately when a PR is created to catch fast CI runs that would +// otherwise complete before the first PR feedback poll. +// Returns the number of watchers spawned. +func SpawnWorkflowWatchers(ctx context.Context, proj *project.Project, ghClient github.ClientInterface, workID, prURL string) (int, error) { + // Fetch PR status to get workflow run information + status, err := ghClient.GetPRStatus(ctx, prURL) + if err != nil { + return 0, fmt.Errorf("failed to get PR status: %w", err) + } + + // Extract repo from PR URL + repo, err := github.ExtractRepoFromPRURL(prURL) + if err != nil { + return 0, fmt.Errorf("failed to extract repo from PR URL: %w", err) + } + + // Check each workflow run for in-progress status + watcherCount := 0 + for _, workflow := range status.Workflows { + // Only watch runs that are in progress or queued + if workflow.Status != "in_progress" && workflow.Status != "queued" { + continue + } + + // Schedule a watcher for this run + err := ScheduleWatchWorkflowRun(ctx, proj, workID, workflow.ID, repo) + if err != nil { + // Log but continue - idempotency key prevents duplicates + logging.Debug("failed to schedule workflow watcher", + "run_id", workflow.ID, + "error", err) + continue + } + watcherCount++ + } + + if watcherCount > 0 { + logging.Info("Spawned workflow watchers", + "count", watcherCount, + "work_id", workID) + } + + return watcherCount, nil +} diff --git a/internal/control/handler_watch_run_test.go b/internal/control/handler_watch_run_test.go index 86fb532..a2c2c6e 100644 --- a/internal/control/handler_watch_run_test.go +++ b/internal/control/handler_watch_run_test.go @@ -7,6 +7,7 @@ import ( "github.com/newhook/co/internal/control" "github.com/newhook/co/internal/db" + "github.com/newhook/co/internal/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -216,3 +217,84 @@ func TestExtractRepoFromPRURL(t *testing.T) { assert.Equal(t, "https://github.com/owner/repo/pull/123", work.PRURL) }) } + +func TestSpawnWorkflowWatchers(t *testing.T) { + ctx := context.Background() + proj, cleanup := setupTestProject(t) + defer cleanup() + + t.Run("spawns watchers for in_progress workflows", func(t *testing.T) { + createTestWork(ctx, t, proj.DB, "w-spawn-1", "branch", "root-1") + defer proj.DB.DeleteWork(ctx, "w-spawn-1") + + mockClient := &github.GitHubClientMock{ + GetPRStatusFunc: func(ctx context.Context, prURL string) (*github.PRStatus, error) { + return &github.PRStatus{ + Workflows: []github.WorkflowRun{ + {ID: 111111, Status: "in_progress", Name: "CI"}, + {ID: 222222, Status: "queued", Name: "Deploy"}, + {ID: 333333, Status: "completed", Name: "Done"}, + }, + }, nil + }, + } + + count, err := control.SpawnWorkflowWatchers(ctx, proj, mockClient, "w-spawn-1", "https://github.com/owner/repo/pull/123") + require.NoError(t, err) + assert.Equal(t, 2, count, "should spawn watchers for in_progress and queued workflows") + + // Verify tasks were scheduled + task1, err := proj.DB.GetTaskByIdempotencyKey(ctx, "watch_run_111111") + require.NoError(t, err) + require.NotNil(t, task1) + assert.Equal(t, "111111", task1.Metadata["run_id"]) + + task2, err := proj.DB.GetTaskByIdempotencyKey(ctx, "watch_run_222222") + require.NoError(t, err) + require.NotNil(t, task2) + assert.Equal(t, "222222", task2.Metadata["run_id"]) + + // Completed workflow should not have a watcher + task3, err := proj.DB.GetTaskByIdempotencyKey(ctx, "watch_run_333333") + require.NoError(t, err) + assert.Nil(t, task3) + }) + + t.Run("returns zero when all workflows completed", func(t *testing.T) { + createTestWork(ctx, t, proj.DB, "w-spawn-2", "branch", "root-1") + defer proj.DB.DeleteWork(ctx, "w-spawn-2") + + mockClient := &github.GitHubClientMock{ + GetPRStatusFunc: func(ctx context.Context, prURL string) (*github.PRStatus, error) { + return &github.PRStatus{ + Workflows: []github.WorkflowRun{ + {ID: 444444, Status: "completed", Name: "CI"}, + }, + }, nil + }, + } + + count, err := control.SpawnWorkflowWatchers(ctx, proj, mockClient, "w-spawn-2", "https://github.com/owner/repo/pull/456") + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + + t.Run("returns error on invalid PR URL", func(t *testing.T) { + createTestWork(ctx, t, proj.DB, "w-spawn-3", "branch", "root-1") + defer proj.DB.DeleteWork(ctx, "w-spawn-3") + + mockClient := &github.GitHubClientMock{ + GetPRStatusFunc: func(ctx context.Context, prURL string) (*github.PRStatus, error) { + return &github.PRStatus{ + Workflows: []github.WorkflowRun{ + {ID: 555555, Status: "in_progress", Name: "CI"}, + }, + }, nil + }, + } + + _, err := control.SpawnWorkflowWatchers(ctx, proj, mockClient, "w-spawn-3", "not-a-valid-url") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to extract repo") + }) +}