fix: associate PR with task when creating task from PR URL (#539)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user