fix: associate PR with task when creating task from PR URL (#539)

This commit is contained in:
Carlos Florêncio
2026-04-03 15:09:27 +01:00
committed by GitHub
parent 51e2338541
commit 99008f448d
4 changed files with 196 additions and 7 deletions
+7 -1
View File
@@ -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)
@@ -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) {
@@ -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(
@@ -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)
})
}