From 99008f448d2c5f471cd657b23f70643eb7dd7fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 3 Apr 2026 15:09:27 +0100 Subject: [PATCH] fix: associate PR with task when creating task from PR URL (#539) --- apps/backend/cmd/kandev/helpers.go | 8 +- .../internal/task/handlers/task_handlers.go | 20 ++- .../task/handlers/task_http_handlers.go | 20 +++ .../task/handlers/task_http_handlers_test.go | 155 ++++++++++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 apps/backend/internal/task/handlers/task_http_handlers_test.go diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 8298568f0e..5dc7c6e87e 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -483,7 +483,13 @@ func registerRoutes(p routeParams) { func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log) - taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log) + taskH := taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log) + if p.services.GitHub != nil { + ghSvc := p.services.GitHub + taskH.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) { + ghSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + }) + } taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorProfileRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.agentList, p.log) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 94f230d5c8..e28e541838 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -23,11 +23,18 @@ type handlerRepo interface { } type TaskHandlers struct { - service *service.Service - orchestrator OrchestratorStarter - repo handlerRepo - planService *service.PlanService - logger *logger.Logger + service *service.Service + orchestrator OrchestratorStarter + repo handlerRepo + planService *service.PlanService + onTaskCreatedWithPR func(ctx context.Context, taskID, sessionID, prURL, branch string) + logger *logger.Logger +} + +// SetOnTaskCreatedWithPR sets a callback invoked when a task is created with a PR URL +// in one of its repository inputs. The callback runs in a background goroutine. +func (h *TaskHandlers) SetOnTaskCreatedWithPR(fn func(ctx context.Context, taskID, sessionID, prURL, branch string)) { + h.onTaskCreatedWithPR = fn } type OrchestratorStarter interface { @@ -45,10 +52,11 @@ func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, rep } } -func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, orchestrator OrchestratorStarter, repo handlerRepo, planService *service.PlanService, log *logger.Logger) { +func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, orchestrator OrchestratorStarter, repo handlerRepo, planService *service.PlanService, log *logger.Logger) *TaskHandlers { handlers := NewTaskHandlers(svc, orchestrator, repo, planService, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) + return handlers } func (h *TaskHandlers) registerHTTP(router *gin.Engine) { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index c3ed49667e..4a386376da 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -447,6 +447,9 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { resolvedStepID := taskDTO.WorkflowStepID h.handlePostCreateTaskSession(c, &response, taskDTO.ID, taskDTO.Description, body, resolvedStepID) + // Associate PR with task if any repository input contains a PR URL + h.associatePRFromRepoInputs(taskDTO.ID, response.TaskSessionID, body.Repositories) + c.JSON(http.StatusOK, response) } @@ -472,6 +475,23 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn return repos, true } +// associatePRFromRepoInputs checks if any repository input contains a GitHub PR URL +// (e.g., github.com/owner/repo/pull/123) and fires the onTaskCreatedWithPR callback +// in a background goroutine to associate the PR with the newly created task. +func (h *TaskHandlers) associatePRFromRepoInputs(taskID, sessionID string, repos []httpTaskRepositoryInput) { + if h.onTaskCreatedWithPR == nil { + return + } + for _, r := range repos { + if r.GitHubURL != "" && strings.Contains(r.GitHubURL, "/pull/") { + prURL := r.GitHubURL + branch := r.CheckoutBranch + go h.onTaskCreatedWithPR(context.Background(), taskID, sessionID, prURL, branch) + return // only one PR per task + } + } +} + // handlePostCreateTaskSession prepares or starts an agent session after a task is created, // depending on the PrepareSession and StartAgent flags in the request body. func (h *TaskHandlers) handlePostCreateTaskSession( diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go new file mode 100644 index 0000000000..eb7819e380 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "debug", + Format: "console", + OutputPath: "stdout", + }) + if err != nil { + t.Fatalf("Failed to create logger: %v", err) + } + return log +} + +func TestAssociatePRFromRepoInputs(t *testing.T) { + log := newTestLogger(t) + + t.Run("calls callback when repo input contains PR URL", func(t *testing.T) { + var mu sync.Mutex + var called bool + var gotTaskID, gotSessionID, gotPRURL, gotBranch string + + h := &TaskHandlers{logger: log} + h.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) { + mu.Lock() + defer mu.Unlock() + called = true + gotTaskID = taskID + gotSessionID = sessionID + gotPRURL = prURL + gotBranch = branch + }) + + // The callback runs in a goroutine, so we need a channel to sync + done := make(chan struct{}) + h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) { + defer close(done) + mu.Lock() + defer mu.Unlock() + called = true + gotTaskID = taskID + gotSessionID = sessionID + gotPRURL = prURL + gotBranch = branch + } + + h.associatePRFromRepoInputs("task-1", "session-1", []httpTaskRepositoryInput{ + { + GitHubURL: "https://github.com/owner/repo/pull/123", + CheckoutBranch: "feature-branch", + }, + }) + + <-done + + mu.Lock() + defer mu.Unlock() + require.True(t, called) + assert.Equal(t, "task-1", gotTaskID) + assert.Equal(t, "session-1", gotSessionID) + assert.Equal(t, "https://github.com/owner/repo/pull/123", gotPRURL) + assert.Equal(t, "feature-branch", gotBranch) + }) + + t.Run("does not call callback for plain repo URLs", func(t *testing.T) { + called := false + h := &TaskHandlers{logger: log} + h.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) { + called = true + }) + + h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{ + { + GitHubURL: "https://github.com/owner/repo", + CheckoutBranch: "main", + }, + }) + + assert.False(t, called) + }) + + t.Run("does not call callback when no onTaskCreatedWithPR set", func(t *testing.T) { + h := &TaskHandlers{logger: log} + // Should not panic + h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{ + { + GitHubURL: "https://github.com/owner/repo/pull/123", + CheckoutBranch: "feature-branch", + }, + }) + }) + + t.Run("passes empty session ID when no session created", func(t *testing.T) { + done := make(chan struct{}) + var gotSessionID string + + h := &TaskHandlers{logger: log} + h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) { + defer close(done) + gotSessionID = sessionID + } + + h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{ + { + GitHubURL: "https://github.com/owner/repo/pull/456", + CheckoutBranch: "fix-branch", + }, + }) + + <-done + assert.Equal(t, "", gotSessionID) + }) + + t.Run("only processes first PR URL when multiple repos have PRs", func(t *testing.T) { + var count int + var mu sync.Mutex + done := make(chan struct{}) + + h := &TaskHandlers{logger: log} + h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) { + defer close(done) + mu.Lock() + defer mu.Unlock() + count++ + } + + h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{ + { + GitHubURL: "https://github.com/owner/repo/pull/1", + CheckoutBranch: "branch-1", + }, + { + GitHubURL: "https://github.com/owner/repo/pull/2", + CheckoutBranch: "branch-2", + }, + }) + + <-done + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, count) + }) +}