diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 285d82df06..dd2b25d39f 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -769,7 +769,8 @@ type httpCreateTaskRequest struct { ProjectID string `json:"project_id,omitempty"` // ExternalID is a caller-supplied identity used for create-idempotency // (docs/specs/tasks/external-id-idempotency/spec.md). - ExternalID string `json:"external_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` + Labels []string `json:"labels,omitempty"` // Office task-handoffs phase 5 — workspace policy. Optional; same // shape as the MCP create_task_kandev fields. WorkspaceMode string `json:"workspace_mode,omitempty"` @@ -804,6 +805,30 @@ var allowedAttachmentTypes = map[string]struct{}{ "resource": {}, } +func encodeTaskLabels(labels []string) (string, error) { + normalized := make([]string, 0, len(labels)) + seen := make(map[string]struct{}, len(labels)) + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, ok := seen[label]; ok { + continue + } + seen[label] = struct{}{} + normalized = append(normalized, label) + } + if len(normalized) == 0 { + return "", nil + } + encoded, err := json.Marshal(normalized) + if err != nil { + return "", err + } + return string(encoded), nil +} + func validateAttachments(items []v1.MessageAttachment) error { if len(items) > maxCreateTaskAttachments { return fmt.Errorf("too many attachments (max %d)", maxCreateTaskAttachments) @@ -858,6 +883,12 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } + labels, err := encodeTaskLabels(body.Labels) + if err != nil { + h.logger.Error("failed to encode task labels", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode task labels"}) + return + } if err := validateAttachments(body.Attachments); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -935,6 +966,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { BlockedBy: body.BlockedBy, StartWhenUnblocked: body.StartWhenUnblocked, ProjectID: body.ProjectID, + Labels: labels, ExternalID: body.ExternalID, }) if err != nil { diff --git a/apps/backend/internal/task/handlers/task_http_labels_test.go b/apps/backend/internal/task/handlers/task_http_labels_test.go new file mode 100644 index 0000000000..ba602f1194 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_http_labels_test.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/task/service" +) + +type labelTaskRepository struct { + mockRepository + mu sync.Mutex + tasks map[string]*models.Task + createCall int +} + +func newLabelTaskRepository() *labelTaskRepository { + return &labelTaskRepository{tasks: make(map[string]*models.Task)} +} + +func (r *labelTaskRepository) GetWorkflow(_ context.Context, id string) (*models.Workflow, error) { + return &models.Workflow{ID: id, WorkspaceID: "ws-1"}, nil +} + +func (r *labelTaskRepository) CreateTask(_ context.Context, task *models.Task) error { + r.mu.Lock() + defer r.mu.Unlock() + copy := *task + r.tasks[task.ID] = © + r.createCall++ + return nil +} + +func (r *labelTaskRepository) GetTask(_ context.Context, id string) (*models.Task, error) { + r.mu.Lock() + defer r.mu.Unlock() + task, ok := r.tasks[id] + if !ok { + return nil, taskrepo.ErrTaskNotFound + } + copy := *task + return ©, nil +} + +func newLabelTaskRouter(t *testing.T, repo *labelTaskRepository) *gin.Engine { + t.Helper() + log := newTestLogger(t) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: log} + router := gin.New() + h.registerHTTP(router) + return router +} + +func serveTaskCreate(router http.Handler, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + return rec +} + +func TestHTTPCreateTaskLabelsPersistsAndRoundTrips(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := newLabelTaskRepository() + router := newLabelTaskRouter(t, repo) + + created := serveTaskCreate(router, `{ + "workspace_id":"ws-1", + "workflow_id":"wf-1", + "title":"Label task", + "labels":[" bug ","triage","bug"," "] + }`) + + require.Equal(t, http.StatusOK, created.Code, created.Body.String()) + var createdBody struct { + ID string `json:"id"` + Labels string `json:"labels"` + } + require.NoError(t, json.Unmarshal(created.Body.Bytes(), &createdBody)) + assert.Equal(t, `["bug","triage"]`, createdBody.Labels) + assert.Equal(t, 1, repo.createCall) + + stored, err := repo.GetTask(context.Background(), createdBody.ID) + require.NoError(t, err) + assert.Equal(t, `["bug","triage"]`, stored.Labels) + + read := httptest.NewRecorder() + readRequest := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+createdBody.ID, nil) + router.ServeHTTP(read, readRequest) + + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + var readBody struct { + Labels string `json:"labels"` + } + require.NoError(t, json.Unmarshal(read.Body.Bytes(), &readBody)) + assert.Equal(t, `["bug","triage"]`, readBody.Labels) +} + +func TestHTTPCreateTaskLabelsMissingAndEmptyUseDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + labelsField string + }{ + {name: "missing"}, + {name: "empty", labelsField: `,"labels":[]`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := newLabelTaskRepository() + router := newLabelTaskRouter(t, repo) + body := `{"workspace_id":"ws-1","workflow_id":"wf-1","title":"Default labels"` + tc.labelsField + `}` + + created := serveTaskCreate(router, body) + + require.Equal(t, http.StatusOK, created.Code, created.Body.String()) + var response struct { + ID string `json:"id"` + Labels string `json:"labels"` + } + require.NoError(t, json.Unmarshal(created.Body.Bytes(), &response)) + assert.Equal(t, `[]`, response.Labels) + assert.Equal(t, 1, repo.createCall) + + stored, err := repo.GetTask(context.Background(), response.ID) + require.NoError(t, err) + assert.Equal(t, `[]`, stored.Labels) + }) + } +} + +func TestHTTPCreateTaskLabelsRejectsInvalidShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + labelsField string + }{ + {name: "scalar", labelsField: `,"labels":"bug"`}, + {name: "mixed array", labelsField: `,"labels":["bug",7]`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := newLabelTaskRepository() + router := newLabelTaskRouter(t, repo) + body := `{"workspace_id":"ws-1","workflow_id":"wf-1","title":"Invalid labels"` + tc.labelsField + `}` + + created := serveTaskCreate(router, body) + + assert.Equal(t, http.StatusBadRequest, created.Code, created.Body.String()) + assert.Equal(t, 0, repo.createCall) + assert.Empty(t, repo.tasks) + }) + } +}