refactor: http handlers와 workitem provider 개선

This commit is contained in:
toki 2026-05-23 20:33:13 +09:00
parent 61283dce9e
commit 0e1fb9cf27
4 changed files with 303 additions and 86 deletions

View file

@ -14,27 +14,27 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
"github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workflow"
"github.com/nomadcode/nomadcode-core/internal/workitem"
) )
type PlaneWorkItemClient interface { type WorkItemReader interface {
GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) FetchWorkItem(ctx context.Context, ref workitem.Ref) (workitem.WorkItem, error)
} }
type Handler struct { type Handler struct {
db *pgxpool.Pool db *pgxpool.Pool
workflow *workflow.Service workflow *workflow.Service
plane PlaneWorkItemClient workItems WorkItemReader
logger *slog.Logger logger *slog.Logger
} }
func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, planeClient PlaneWorkItemClient, logger *slog.Logger) *Handler { func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, workItemReader WorkItemReader, logger *slog.Logger) *Handler {
return &Handler{ return &Handler{
db: pool, db: pool,
workflow: workflowService, workflow: workflowService,
plane: planeClient, workItems: workItemReader,
logger: logger, logger: logger,
} }
} }
@ -85,7 +85,7 @@ type createPlaneTaskRequest struct {
} }
func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if h.plane == nil { if h.workItems == nil {
writeError(w, stdhttp.StatusServiceUnavailable, "plane client is not configured") writeError(w, stdhttp.StatusServiceUnavailable, "plane client is not configured")
return return
} }
@ -102,13 +102,13 @@ func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request)
return return
} }
workItem, err := h.plane.GetWorkItem(r.Context(), ref) item, err := h.workItems.FetchWorkItem(r.Context(), ref)
if err != nil { if err != nil {
h.writeServiceError(w, err) h.writeServiceError(w, err)
return return
} }
createInput, err := buildPlaneCreateTaskInput(input, workItem) createInput, err := buildPlaneCreateTaskInput(input, item)
if err != nil { if err != nil {
writeError(w, stdhttp.StatusBadRequest, err.Error()) writeError(w, stdhttp.StatusBadRequest, err.Error())
return return
@ -173,72 +173,32 @@ func (h *Handler) EnqueueTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
}) })
} }
func (input createPlaneTaskRequest) workItemRef() (plane.WorkItemRef, error) { func (input createPlaneTaskRequest) workItemRef() (workitem.Ref, error) {
ref := plane.WorkItemRef{ tenant := strings.TrimSpace(input.WorkspaceSlug)
WorkspaceSlug: strings.TrimSpace(input.WorkspaceSlug), project := strings.TrimSpace(input.ProjectID)
ProjectID: strings.TrimSpace(input.ProjectID), id := strings.TrimSpace(input.WorkItemID)
WorkItemID: strings.TrimSpace(input.WorkItemID), if tenant == "" || project == "" || id == "" {
return workitem.Ref{}, errors.New("workspace_slug, project_id, and work_item_id are required")
} }
if ref.WorkspaceSlug == "" || ref.ProjectID == "" || ref.WorkItemID == "" { return workitem.NormalizeRef(workitem.Ref{
return plane.WorkItemRef{}, errors.New("workspace_slug, project_id, and work_item_id are required") Provider: workitem.ProviderID("plane"),
} Tenant: tenant,
return ref, nil Project: project,
ID: id,
URL: strings.TrimSpace(input.ExternalURL),
})
} }
func buildPlaneCreateTaskInput(input createPlaneTaskRequest, workItem plane.WorkItem) (workflow.CreateTaskInput, error) { func buildPlaneCreateTaskInput(input createPlaneTaskRequest, item workitem.WorkItem) (workflow.CreateTaskInput, error) {
ref, err := input.workItemRef() ref, err := input.workItemRef()
if err != nil { if err != nil {
return workflow.CreateTaskInput{}, err return workflow.CreateTaskInput{}, err
} }
return workitem.BuildCreateTaskInput(workitem.TaskCreateInput{
title := strings.TrimSpace(workItem.Name) Ref: ref,
if title == "" { StateID: input.StateID,
title = ref.WorkItemID Comment: input.Comment,
} }, item)
stateID := strings.TrimSpace(input.StateID)
message := firstNonEmpty(input.Comment, workItem.DescriptionStripped, workItem.Description, workItem.DescriptionHTML, title)
metadata := map[string]string{
"workspace_slug": ref.WorkspaceSlug,
"project_id": ref.ProjectID,
"work_item_id": ref.WorkItemID,
"state_id": stateID,
"external_url": strings.TrimSpace(input.ExternalURL),
}
payload := map[string]any{
"message": message,
"plane": metadata,
}
rawPayload, err := json.Marshal(payload)
if err != nil {
return workflow.CreateTaskInput{}, err
}
rawMetadata, err := json.Marshal(metadata)
if err != nil {
return workflow.CreateTaskInput{}, err
}
return workflow.CreateTaskInput{
Title: title,
Source: "plane",
Payload: rawPayload,
External: &workflow.ExternalRefInput{
Provider: "plane",
ID: ref.WorkItemID,
URL: strings.TrimSpace(input.ExternalURL),
Metadata: rawMetadata,
},
}, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
} }
func stringValue(value *string) string { func stringValue(value *string) string {

View file

@ -4,19 +4,47 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane" "github.com/nomadcode/nomadcode-core/internal/workitem"
) )
func TestPlaneTaskRequestBuildsProviderNeutralRef(t *testing.T) {
req := createPlaneTaskRequest{
WorkspaceSlug: " acme ",
ProjectID: " proj-1 ",
WorkItemID: " work-1 ",
ExternalURL: " https://plane.example/work-1 ",
}
ref, err := req.workItemRef()
if err != nil {
t.Fatalf("workItemRef returned error: %v", err)
}
if ref.Provider != workitem.ProviderID("plane") {
t.Errorf("provider: got %q", ref.Provider)
}
if ref.Tenant != "acme" {
t.Errorf("tenant: got %q", ref.Tenant)
}
if ref.Project != "proj-1" {
t.Errorf("project: got %q", ref.Project)
}
if ref.ID != "work-1" {
t.Errorf("id: got %q", ref.ID)
}
if ref.URL != "https://plane.example/work-1" {
t.Errorf("url: got %q", ref.URL)
}
}
func TestBuildPlaneCreateTaskInputUsesCommentBeforeDescription(t *testing.T) { func TestBuildPlaneCreateTaskInputUsesCommentBeforeDescription(t *testing.T) {
input, err := buildPlaneCreateTaskInput(createPlaneTaskRequest{ input, err := buildPlaneCreateTaskInput(createPlaneTaskRequest{
WorkspaceSlug: "general", WorkspaceSlug: "general",
ProjectID: "project-1", ProjectID: "project-1",
WorkItemID: "work-1", WorkItemID: "work-1",
Comment: "operator instruction", Comment: "operator instruction",
}, plane.WorkItem{ }, workitem.WorkItem{
ID: "work-1", Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"},
Name: "Fix issue", Title: "Fix issue",
DescriptionStripped: "plane description", DescriptionText: "plane description",
}) })
if err != nil { if err != nil {
t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err) t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err)
@ -44,9 +72,9 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) {
WorkItemID: "work-1", WorkItemID: "work-1",
StateID: "state-1", StateID: "state-1",
ExternalURL: "https://plane.example/work-1", ExternalURL: "https://plane.example/work-1",
}, plane.WorkItem{ }, workitem.WorkItem{
ID: "work-1", Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"},
Name: "Fix issue", Title: "Fix issue",
}) })
if err != nil { if err != nil {
t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err) t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err)
@ -62,10 +90,12 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) {
if err := json.Unmarshal(input.External.Metadata, &metadata); err != nil { if err := json.Unmarshal(input.External.Metadata, &metadata); err != nil {
t.Fatalf("decode metadata: %v", err) t.Fatalf("decode metadata: %v", err)
} }
if metadata["workspace_slug"] != "general" || if metadata["provider"] != "plane" ||
metadata["project_id"] != "project-1" || metadata["tenant"] != "general" ||
metadata["work_item_id"] != "work-1" || metadata["project"] != "project-1" ||
metadata["state_id"] != "state-1" { metadata["id"] != "work-1" ||
metadata["state_id"] != "state-1" ||
metadata["external_url"] != "https://plane.example/work-1" {
t.Fatalf("unexpected metadata: %#v", metadata) t.Fatalf("unexpected metadata: %#v", metadata)
} }
} }

View file

@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"strings" "strings"
"github.com/nomadcode/nomadcode-core/internal/workflow"
) )
var ErrInvalidRef = errors.New("invalid workitem ref") var ErrInvalidRef = errors.New("invalid workitem ref")
@ -116,6 +118,69 @@ func (m Mapping) LabelTarget(canonical string) (string, bool) {
return target, target != "" return target, target != ""
} }
type TaskCreateInput struct {
Ref Ref
StateID string
Comment string
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if trimmed := strings.TrimSpace(s); trimmed != "" {
return trimmed
}
}
return ""
}
func BuildCreateTaskInput(input TaskCreateInput, item WorkItem) (workflow.CreateTaskInput, error) {
ref, err := NormalizeRef(input.Ref)
if err != nil {
return workflow.CreateTaskInput{}, err
}
title := strings.TrimSpace(item.Title)
if title == "" {
title = ref.ID
}
message := firstNonEmpty(input.Comment, item.DescriptionText, item.DescriptionHTML, title)
metadata := map[string]string{
"provider": string(ref.Provider),
"tenant": ref.Tenant,
"project": ref.Project,
"id": ref.ID,
"state_id": strings.TrimSpace(input.StateID),
"external_url": ref.URL,
}
payloadBytes, err := json.Marshal(map[string]any{
"message": message,
"work_item": metadata,
})
if err != nil {
return workflow.CreateTaskInput{}, err
}
metadataBytes, err := json.Marshal(metadata)
if err != nil {
return workflow.CreateTaskInput{}, err
}
return workflow.CreateTaskInput{
Title: title,
Source: string(ref.Provider),
Payload: payloadBytes,
External: &workflow.ExternalRefInput{
Provider: string(ref.Provider),
ID: ref.ID,
URL: ref.URL,
Metadata: metadataBytes,
},
}, nil
}
func NormalizeRef(ref Ref) (Ref, error) { func NormalizeRef(ref Ref) (Ref, error) {
provider := ProviderID(strings.TrimSpace(string(ref.Provider))) provider := ProviderID(strings.TrimSpace(string(ref.Provider)))
id := strings.TrimSpace(ref.ID) id := strings.TrimSpace(ref.ID)

View file

@ -118,3 +118,165 @@ func TestMappingReturnsTrimmedProviderTargets(t *testing.T) {
t.Error("LabelTarget missing: expected false") t.Error("LabelTarget missing: expected false")
} }
} }
func TestBuildCreateTaskInputUsesCommentBeforeDescription(t *testing.T) {
input := TaskCreateInput{
Ref: Ref{Provider: "plane", ID: "work-1", Tenant: "acme", Project: "proj-1"},
StateID: "state-abc",
Comment: "my comment",
}
item := WorkItem{
Ref: input.Ref,
Title: "Issue Title",
DescriptionText: "description text",
}
out, err := BuildCreateTaskInput(input, item)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Title != "Issue Title" {
t.Errorf("title: got %q", out.Title)
}
if out.Source != "plane" {
t.Errorf("source: got %q", out.Source)
}
if out.External == nil || out.External.Provider != "plane" {
t.Errorf("external.provider: got %v", out.External)
}
var payload map[string]any
if err := json.Unmarshal(out.Payload, &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
if payload["message"] != "my comment" {
t.Errorf("message: got %v, want 'my comment'", payload["message"])
}
}
func TestBuildCreateTaskInputStoresProviderNeutralMetadata(t *testing.T) {
input := TaskCreateInput{
Ref: Ref{Provider: "plane", ID: "work-2", Tenant: "acme", Project: "proj-2", URL: "https://example.com/work/2"},
StateID: " state-xyz ",
}
item := WorkItem{
Ref: input.Ref,
Title: "Task",
}
out, err := BuildCreateTaskInput(input, item)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Payload, &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
wi, ok := payload["work_item"].(map[string]any)
if !ok {
t.Fatalf("work_item missing or wrong type: %v", payload["work_item"])
}
checks := map[string]string{
"provider": "plane",
"tenant": "acme",
"project": "proj-2",
"id": "work-2",
"state_id": "state-xyz",
"external_url": "https://example.com/work/2",
}
for k, want := range checks {
if wi[k] != want {
t.Errorf("work_item[%q]: got %v, want %q", k, wi[k], want)
}
}
var extMeta map[string]any
if err := json.Unmarshal(out.External.Metadata, &extMeta); err != nil {
t.Fatalf("external metadata unmarshal: %v", err)
}
for k, want := range checks {
if extMeta[k] != want {
t.Errorf("external.metadata[%q]: got %v, want %q", k, extMeta[k], want)
}
}
}
func TestBuildCreateTaskInputSkipsWhitespaceMessageCandidates(t *testing.T) {
ref := Ref{Provider: "plane", ID: "work-4", Tenant: "acme", Project: "proj-1"}
// whitespace comment -> falls back to DescriptionText, which is trimmed
out, err := BuildCreateTaskInput(
TaskCreateInput{Ref: ref, Comment: " "},
WorkItem{Ref: ref, Title: "T", DescriptionText: " description text "},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Payload, &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
if payload["message"] != "description text" {
t.Errorf("message: got %v, want 'description text'", payload["message"])
}
// comment and DescriptionText both whitespace -> falls back to DescriptionHTML
out, err = BuildCreateTaskInput(
TaskCreateInput{Ref: ref, Comment: " "},
WorkItem{Ref: ref, Title: "T", DescriptionText: " ", DescriptionHTML: "<p>html</p>"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := json.Unmarshal(out.Payload, &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
if payload["message"] != "<p>html</p>" {
t.Errorf("message: got %v, want '<p>html</p>'", payload["message"])
}
// all candidates whitespace -> falls back to title (trimmed)
out, err = BuildCreateTaskInput(
TaskCreateInput{Ref: ref, Comment: " "},
WorkItem{Ref: ref, Title: " My Title ", DescriptionText: " ", DescriptionHTML: " "},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := json.Unmarshal(out.Payload, &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
if payload["message"] != "My Title" {
t.Errorf("message: got %v, want 'My Title'", payload["message"])
}
}
func TestBuildCreateTaskInputFallsBackToRefIDAndRequiresValidRef(t *testing.T) {
input := TaskCreateInput{
Ref: Ref{Provider: "plane", ID: "work-3"},
}
item := WorkItem{
Ref: input.Ref,
Title: " ",
}
out, err := BuildCreateTaskInput(input, item)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Title != "work-3" {
t.Errorf("title fallback: got %q, want 'work-3'", out.Title)
}
_, err = BuildCreateTaskInput(TaskCreateInput{Ref: Ref{Provider: "", ID: "work-3"}}, item)
if !errors.Is(err, ErrInvalidRef) {
t.Errorf("missing provider: expected ErrInvalidRef, got %v", err)
}
_, err = BuildCreateTaskInput(TaskCreateInput{Ref: Ref{Provider: "plane", ID: ""}}, item)
if !errors.Is(err, ErrInvalidRef) {
t.Errorf("missing id: expected ErrInvalidRef, got %v", err)
}
}