Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cmd/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
47 changes: 47 additions & 0 deletions internal/control/handler_watch_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
82 changes: 82 additions & 0 deletions internal/control/handler_watch_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
})
}