From f1f62d3da5f21594df07b1899bac56f679e17de5 Mon Sep 17 00:00:00 2001 From: Matthew Newhook Date: Sun, 1 Feb 2026 17:02:55 -0500 Subject: [PATCH] Spawn workflow watchers immediately when PR is created Previously, workflow watchers were only spawned during PR feedback polling, which meant fast CI runs could complete before the first poll. This created a race condition where gh run watch was never used for quick CI pipelines. Now, when a PR URL is provided to `co complete --pr`, we immediately fetch the PR's workflow runs and spawn watchers for any that are in_progress or queued. This ensures immediate notification when CI completes. Changes: - Add exported SpawnWorkflowWatchers function in control package - Call SpawnWorkflowWatchers from cmd/complete.go after setting PR URL - Add tests for the new function Co-Authored-By: Claude Opus 4.5 --- cmd/complete.go | 12 ++++ internal/control/handler_watch_run.go | 47 +++++++++++++ internal/control/handler_watch_run_test.go | 82 ++++++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/cmd/complete.go b/cmd/complete.go index a016dc80..2922e490 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 ce823957..532ab1dc 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 86fb5326..a2c2c6e0 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") + }) +}