From 401488f29919f1abec400188787c7ccd8501e88a Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 23 May 2026 18:47:30 +0900 Subject: [PATCH] add workitem provider and update plane client integration - Add workitem package with provider interface for workspace item management - Update plane client to support workspace item operations - Add tests for workitem provider - Update roadmap current.md and plane-task-pipeline-design milestone --- agent-ops/roadmap/current.md | 2 +- .../milestones/plane-task-pipeline-design.md | 21 +-- .../core/internal/adapters/plane/client.go | 90 +++++++++++ .../internal/adapters/plane/client_test.go | 140 +++++++++++++++++ services/core/internal/workitem/provider.go | 141 ++++++++++++++++++ .../core/internal/workitem/provider_test.go | 120 +++++++++++++++ 6 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 services/core/internal/workitem/provider.go create mode 100644 services/core/internal/workitem/provider_test.go diff --git a/agent-ops/roadmap/current.md b/agent-ops/roadmap/current.md index 117f2ea..56cbcf5 100644 --- a/agent-ops/roadmap/current.md +++ b/agent-ops/roadmap/current.md @@ -2,7 +2,7 @@ ## 활성 Milestone -- Work Item Provider Pipeline Design (상태: 진행 중, 현재 항목: work item provider adapter interface 설계): agent-ops/roadmap/milestones/plane-task-pipeline-design.md +- Work Item Provider Pipeline Design: agent-ops/roadmap/milestones/plane-task-pipeline-design.md ## 선택 규칙 diff --git a/agent-ops/roadmap/milestones/plane-task-pipeline-design.md b/agent-ops/roadmap/milestones/plane-task-pipeline-design.md index 0f7246d..b4d37cd 100644 --- a/agent-ops/roadmap/milestones/plane-task-pipeline-design.md +++ b/agent-ops/roadmap/milestones/plane-task-pipeline-design.md @@ -40,11 +40,11 @@ Work Item Provider Pipeline Design - [x] worker profile과 실행 단계는 core metadata와 label/comment prefix로 표현한다. - [x] webhook-first trigger와 polling fallback의 세부 조건을 확정한다. - [x] `worker:*`, `agent:*`, `phase:*` label은 실행 조건이 아니라 worker profile/실행 상태 표시용으로 둔다. -- [ ] work item provider adapter interface를 설계한다. - - [ ] core pipeline이 사용할 provider-neutral DTO를 정의한다. - - [ ] work item 조회, comment 작성, status/state 변경, label projection 경계를 interface로 분리한다. - - [ ] Plane adapter와 Jira adapter가 같은 interface를 구현할 수 있는지 검증한다. - - [ ] provider별 상태/라벨/comment 매핑은 adapter 설정으로 분리한다. +- [x] work item provider adapter interface를 설계한다. + - [x] core pipeline이 사용할 provider-neutral DTO를 정의한다. + - [x] work item 조회, comment 작성, status/state 변경, label projection 경계를 interface로 분리한다. + - [x] Plane adapter와 Jira adapter가 같은 interface를 구현할 수 있는지 검증한다. + - [x] provider별 상태/라벨/comment 매핑은 adapter 설정으로 분리한다. - [ ] 현재 Plane 고정 진입부를 provider-neutral 구조로 리팩토링한다. - [ ] `POST /api/integrations/plane/tasks`의 Plane 전용 흐름을 generic pipeline service 아래로 옮긴다. - [ ] HTTP handler가 Plane 타입과 직접 결합하지 않도록 요청 DTO와 변환 책임을 분리한다. @@ -94,6 +94,8 @@ Work Item Provider Pipeline Design - HTTP router는 `POST /api/integrations/plane/tasks`로 Plane 전용 entrypoint를 가진다. - handler는 `PlaneWorkItemClient`, `plane.WorkItemRef`, `plane.WorkItem`에 직접 의존한다. - `buildPlaneCreateTaskInput`이 Plane 조회 결과를 core task payload/external ref로 직접 변환한다. + - `services/core/internal/workitem`은 provider-neutral DTO, optional facet interface, projection mapping을 제공한다. + - Plane adapter는 `workitem.Provider`, `Reader`, `Commenter`, `StatusProjector`를 구현하며 `LabelProjector`는 capability false로 남긴다. - DB schema는 `external_provider`, `external_id`, `external_url`, `external_metadata`로 provider-neutral 토대가 있으므로 유지한다. - 리팩토링 목표는 Plane/Jira 직접 접속부를 adapter로 격리하고, 그 아래 pipeline/service 계층은 provider-neutral DTO와 interface만 보게 하는 것이다. - 초기 생성/연결 흐름: @@ -132,6 +134,7 @@ Work Item Provider Pipeline Design - provider abstraction 결정: - Plane은 첫 구현 provider일 뿐이며 core pipeline의 도메인 모델이 되어서는 안 된다. - Jira도 같은 workflow state, label/comment projection, idempotency 계약을 공유할 수 있어야 한다. + - provider-neutral adapter contract는 `workitem.Ref`, `WorkItem`, `CommentInput`, `StatusProjection`, `LabelProjection`, `Mapping`으로 둔다. - provider별 API 인증, URL, workspace/project/issue 식별자, status id, label id는 adapter 설정과 metadata로만 다룬다. - core의 canonical 상태와 agent 실행 metadata는 provider에서 읽어온 값이 아니라 NomadCode task 상태의 진실로 둔다. - comment 기록 규칙 후보: @@ -141,10 +144,10 @@ Work Item Provider Pipeline Design - `✅ VERIFY | <요약>`: 테스트/검증 완료 기록 - 각 comment 본문은 1~2줄 요약을 기본으로 하며, 자세한 실행 로그나 상태 metadata는 core에 남긴다. - 현재 지점 / 착수 상태: - - 현재 상태는 `진행 중`이며, 구현 착수 전 설계 정리 지점이다. - - 현재 진행작업은 `work item provider adapter interface를 설계한다` 항목이다. + - 현재 상태는 `진행 중`이며, provider adapter interface 설계와 Plane adapter facet 검증은 완료됐다. + - 다음 진행 후보는 `현재 Plane 고정 진입부를 provider-neutral 구조로 리팩토링한다` 항목이다. - 정리된 내용은 provider-neutral 방향성, Plane/Jira adapter 추상화 필요성, label/comment 기반 상태 투영 샘플이다. - 직전 항목인 `자동 enqueue 여부와 사용자/운영 트리거 경계를 결정한다`는 완료됐다. - - 이후 남은 큰 결정은 provider adapter interface, core metadata schema, provider label mapping이다. - - 다음에 이 마일스톤을 착수하면 Plane 고정 진입부 리팩토링 전에 위 결정 항목을 먼저 닫는다. + - 이후 남은 큰 결정은 Plane 고정 진입부 전환, core metadata schema, provider label mapping이다. + - 다음에 이 마일스톤을 착수하면 Plane 고정 진입부 리팩토링에서 HTTP handler의 Plane 타입 의존을 끊는다. - 확인 필요: trigger 방식은 현재 구현 상태와 운영 기대치를 보고 수동 endpoint 유지, webhook, polling 중 하나를 선택한다. diff --git a/services/core/internal/adapters/plane/client.go b/services/core/internal/adapters/plane/client.go index 057b5e1..b0dd9e4 100644 --- a/services/core/internal/adapters/plane/client.go +++ b/services/core/internal/adapters/plane/client.go @@ -11,6 +11,15 @@ import ( "net/http" "net/url" "strings" + + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +var ( + _ workitem.Provider = (*Client)(nil) + _ workitem.Reader = (*Client)(nil) + _ workitem.Commenter = (*Client)(nil) + _ workitem.StatusProjector = (*Client)(nil) ) var ( @@ -73,6 +82,87 @@ func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.L return &Client{cfg: cfg, http: httpClient, logger: logger} } +func (c *Client) ProviderID() workitem.ProviderID { + return workitem.ProviderID("plane") +} + +func (c *Client) Capabilities() workitem.Capabilities { + return workitem.Capabilities{ + workitem.CapabilityLookup: true, + workitem.CapabilityComment: true, + workitem.CapabilityStatusProjection: true, + workitem.CapabilityLabelProjection: false, + } +} + +func (c *Client) FetchWorkItem(ctx context.Context, ref workitem.Ref) (workitem.WorkItem, error) { + planeRef, err := planeWorkItemRef(ref) + if err != nil { + return workitem.WorkItem{}, err + } + item, err := c.GetWorkItem(ctx, planeRef) + if err != nil { + return workitem.WorkItem{}, err + } + return workitem.WorkItem{ + Ref: ref, + Title: item.Name, + DescriptionText: firstNonEmpty(item.DescriptionStripped, item.Description), + DescriptionHTML: item.DescriptionHTML, + }, nil +} + +func (c *Client) AppendComment(ctx context.Context, input workitem.CommentInput) error { + planeRef, err := planeWorkItemRef(input.Ref) + if err != nil { + return err + } + return c.AddComment(ctx, AddCommentInput{ + Ref: planeRef, + CommentHTML: input.Body, + Access: input.Visibility, + ExternalSource: input.ExternalSource, + ExternalID: input.ExternalID, + }) +} + +func (c *Client) ProjectStatus(ctx context.Context, input workitem.StatusProjection) error { + planeRef, err := planeWorkItemRef(input.Ref) + if err != nil { + return err + } + if strings.TrimSpace(input.ProviderState) == "" { + return ErrInvalidInput + } + return c.UpdateIssueStatus(ctx, UpdateIssueStatusInput{ + Ref: planeRef, + Status: input.ProviderState, + }) +} + +func planeWorkItemRef(ref workitem.Ref) (WorkItemRef, error) { + tenant := strings.TrimSpace(ref.Tenant) + project := strings.TrimSpace(ref.Project) + id := strings.TrimSpace(ref.ID) + if tenant == "" || project == "" || id == "" { + return WorkItemRef{}, ErrInvalidInput + } + return WorkItemRef{ + WorkspaceSlug: tenant, + ProjectID: project, + WorkItemID: id, + }, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error { c.log(ctx, "plane create issue skipped", "title", input.Title) return ErrNotImplemented diff --git a/services/core/internal/adapters/plane/client_test.go b/services/core/internal/adapters/plane/client_test.go index ecdb96c..433ef25 100644 --- a/services/core/internal/adapters/plane/client_test.go +++ b/services/core/internal/adapters/plane/client_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/nomadcode/nomadcode-core/internal/workitem" ) func TestGetWorkItemUsesWorkItemsEndpointAndAPIKey(t *testing.T) { @@ -130,6 +132,144 @@ func TestClientReturnsResponseBodyOnError(t *testing.T) { } } +func TestClientImplementsWorkItemFacets(t *testing.T) { + client := NewClient(Config{BaseURL: "https://example.com", Token: "test-token"}, nil) + if client.ProviderID() != workitem.ProviderID("plane") { + t.Errorf("provider ID: got %q", client.ProviderID()) + } + caps := client.Capabilities() + if !caps.Supports(workitem.CapabilityLookup) { + t.Error("expected lookup capability") + } + if !caps.Supports(workitem.CapabilityComment) { + t.Error("expected comment capability") + } + if !caps.Supports(workitem.CapabilityStatusProjection) { + t.Error("expected status projection capability") + } + if caps.Supports(workitem.CapabilityLabelProjection) { + t.Error("label projection should be unsupported") + } +} + +func TestFetchWorkItemMapsNeutralRefToPlaneEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method: got %s", r.Method) + } + if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1" { + t.Fatalf("path: got %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "work-1", + "name": "Fix issue", + "description_stripped": "Do the thing", + "description_html": "

Do the thing

", + }) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil) + ref := workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"} + item, err := client.FetchWorkItem(context.Background(), ref) + if err != nil { + t.Fatalf("FetchWorkItem returned error: %v", err) + } + if item.Title != "Fix issue" { + t.Errorf("title: got %q", item.Title) + } + if item.DescriptionText != "Do the thing" { + t.Errorf("description_text: got %q", item.DescriptionText) + } + if item.DescriptionHTML != "

Do the thing

" { + t.Errorf("description_html: got %q", item.DescriptionHTML) + } + if item.Ref.ID != "work-1" { + t.Errorf("ref.ID: got %q", item.Ref.ID) + } +} + +func TestAppendCommentMapsNeutralComment(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method: got %s", r.Method) + } + if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/comments/" { + t.Fatalf("path: got %s", r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["comment_html"] != "

done

" { + t.Errorf("comment_html: got %q", body["comment_html"]) + } + if body["external_source"] != "nomadcode" { + t.Errorf("external_source: got %q", body["external_source"]) + } + if body["external_id"] != "task-1" { + t.Errorf("external_id: got %q", body["external_id"]) + } + if body["access"] != "INTERNAL" { + t.Errorf("access: got %q", body["access"]) + } + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil) + err := client.AppendComment(context.Background(), workitem.CommentInput{ + Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"}, + Body: "

done

", + Format: workitem.CommentFormatHTML, + ExternalSource: "nomadcode", + ExternalID: "task-1", + Visibility: "INTERNAL", + }) + if err != nil { + t.Fatalf("AppendComment returned error: %v", err) + } +} + +func TestProjectStatusRequiresProviderState(t *testing.T) { + client := NewClient(Config{BaseURL: "https://example.com", Token: "test-token"}, nil) + err := client.ProjectStatus(context.Background(), workitem.StatusProjection{ + Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"}, + ProviderState: "", + }) + if err == nil || !strings.Contains(err.Error(), ErrInvalidInput.Error()) { + t.Fatalf("empty provider state: expected ErrInvalidInput, got %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Fatalf("method: got %s", r.Method) + } + if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/" { + t.Fatalf("path: got %s", r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["state"] != "state-done" { + t.Errorf("state: got %q", body["state"]) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client2 := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil) + err = client2.ProjectStatus(context.Background(), workitem.StatusProjection{ + Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"}, + CanonicalState: "done", + ProviderState: "state-done", + }) + if err != nil { + t.Fatalf("ProjectStatus returned error: %v", err) + } +} + func TestClientRequiresBaseURLAndToken(t *testing.T) { client := NewClient(Config{}, nil) _, err := client.GetWorkItem(context.Background(), WorkItemRef{ diff --git a/services/core/internal/workitem/provider.go b/services/core/internal/workitem/provider.go new file mode 100644 index 0000000..cd0da55 --- /dev/null +++ b/services/core/internal/workitem/provider.go @@ -0,0 +1,141 @@ +package workitem + +import ( + "context" + "encoding/json" + "errors" + "strings" +) + +var ErrInvalidRef = errors.New("invalid workitem ref") + +type ProviderID string + +type Capability string + +const ( + CapabilityLookup Capability = "lookup" + CapabilityComment Capability = "comment" + CapabilityStatusProjection Capability = "status_projection" + CapabilityLabelProjection Capability = "label_projection" +) + +type Capabilities map[Capability]bool + +func (c Capabilities) Supports(capability Capability) bool { + return c[capability] +} + +type Provider interface { + ProviderID() ProviderID + Capabilities() Capabilities +} + +type Reader interface { + FetchWorkItem(ctx context.Context, ref Ref) (WorkItem, error) +} + +type Commenter interface { + AppendComment(ctx context.Context, input CommentInput) error +} + +type StatusProjector interface { + ProjectStatus(ctx context.Context, input StatusProjection) error +} + +type LabelProjector interface { + ProjectLabels(ctx context.Context, input LabelProjection) error +} + +type Ref struct { + Provider ProviderID `json:"provider"` + Tenant string `json:"tenant"` + Project string `json:"project"` + ID string `json:"id"` + URL string `json:"url,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type WorkItem struct { + Ref Ref `json:"ref"` + Title string `json:"title"` + DescriptionText string `json:"description_text,omitempty"` + DescriptionHTML string `json:"description_html,omitempty"` + State string `json:"state,omitempty"` + AssigneeIDs []string `json:"assignee_ids,omitempty"` + Labels []string `json:"labels,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type CommentFormat string + +const ( + CommentFormatText CommentFormat = "text" + CommentFormatMarkdown CommentFormat = "markdown" + CommentFormatHTML CommentFormat = "html" +) + +type CommentInput struct { + Ref Ref `json:"ref"` + Body string `json:"body"` + Format CommentFormat `json:"format"` + ExternalSource string `json:"external_source,omitempty"` + ExternalID string `json:"external_id,omitempty"` + Visibility string `json:"visibility,omitempty"` +} + +type StatusProjection struct { + Ref Ref `json:"ref"` + CanonicalState string `json:"canonical_state"` + ProviderState string `json:"provider_state"` +} + +type LabelTarget struct { + Canonical string `json:"canonical"` + Provider string `json:"provider"` +} + +type LabelProjection struct { + Ref Ref `json:"ref"` + Add []LabelTarget `json:"add,omitempty"` + Remove []LabelTarget `json:"remove,omitempty"` +} + +type Mapping struct { + States map[string]string `json:"states,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +func (m Mapping) StateTarget(canonical string) (string, bool) { + target := strings.TrimSpace(m.States[strings.TrimSpace(canonical)]) + return target, target != "" +} + +func (m Mapping) LabelTarget(canonical string) (string, bool) { + target := strings.TrimSpace(m.Labels[strings.TrimSpace(canonical)]) + return target, target != "" +} + +func NormalizeRef(ref Ref) (Ref, error) { + provider := ProviderID(strings.TrimSpace(string(ref.Provider))) + id := strings.TrimSpace(ref.ID) + if provider == "" || id == "" { + return Ref{}, ErrInvalidRef + } + + meta := ref.Metadata + if len(meta) == 0 { + meta = json.RawMessage("{}") + } else if !json.Valid(meta) { + return Ref{}, ErrInvalidRef + } + + return Ref{ + Provider: provider, + Tenant: strings.TrimSpace(ref.Tenant), + Project: strings.TrimSpace(ref.Project), + ID: id, + URL: strings.TrimSpace(ref.URL), + Metadata: meta, + }, nil +} diff --git a/services/core/internal/workitem/provider_test.go b/services/core/internal/workitem/provider_test.go new file mode 100644 index 0000000..9488433 --- /dev/null +++ b/services/core/internal/workitem/provider_test.go @@ -0,0 +1,120 @@ +package workitem + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestNormalizeRefTrimsAndDefaultsMetadata(t *testing.T) { + ref, err := NormalizeRef(Ref{ + Provider: " plane ", + Tenant: " general ", + Project: " project-1 ", + ID: " work-1 ", + URL: " https://example.com ", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ref.Provider != "plane" { + t.Errorf("provider: got %q", ref.Provider) + } + if ref.Tenant != "general" { + t.Errorf("tenant: got %q", ref.Tenant) + } + if ref.Project != "project-1" { + t.Errorf("project: got %q", ref.Project) + } + if ref.ID != "work-1" { + t.Errorf("id: got %q", ref.ID) + } + if ref.URL != "https://example.com" { + t.Errorf("url: got %q", ref.URL) + } + if string(ref.Metadata) != "{}" { + t.Errorf("metadata: got %q", ref.Metadata) + } +} + +func TestNormalizeRefKeepsValidMetadata(t *testing.T) { + meta := json.RawMessage(`{"key":"val"}`) + ref, err := NormalizeRef(Ref{ + Provider: "plane", + ID: "work-1", + Metadata: meta, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(ref.Metadata) != `{"key":"val"}` { + t.Errorf("metadata: got %q", ref.Metadata) + } +} + +func TestNormalizeRefRequiresProviderAndID(t *testing.T) { + _, err := NormalizeRef(Ref{Provider: "", ID: "work-1"}) + if !errors.Is(err, ErrInvalidRef) { + t.Errorf("empty provider: expected ErrInvalidRef, got %v", err) + } + + _, err = NormalizeRef(Ref{Provider: "plane", ID: ""}) + if !errors.Is(err, ErrInvalidRef) { + t.Errorf("empty id: expected ErrInvalidRef, got %v", err) + } + + _, err = NormalizeRef(Ref{Provider: " ", ID: " "}) + if !errors.Is(err, ErrInvalidRef) { + t.Errorf("whitespace-only: expected ErrInvalidRef, got %v", err) + } +} + +func TestNormalizeRefRejectsInvalidMetadata(t *testing.T) { + _, err := NormalizeRef(Ref{ + Provider: "plane", + ID: "work-1", + Metadata: json.RawMessage("not-json"), + }) + if !errors.Is(err, ErrInvalidRef) { + t.Errorf("expected ErrInvalidRef, got %v", err) + } +} + +func TestMappingReturnsTrimmedProviderTargets(t *testing.T) { + m := Mapping{ + States: map[string]string{ + "done": " state-done ", + "empty": "", + }, + Labels: map[string]string{ + "bug": " label-bug ", + "empty": "", + }, + } + + target, ok := m.StateTarget("done") + if !ok || target != "state-done" { + t.Errorf("StateTarget done: got %q, ok=%v", target, ok) + } + _, ok = m.StateTarget("empty") + if ok { + t.Error("StateTarget empty: expected false") + } + _, ok = m.StateTarget("missing") + if ok { + t.Error("StateTarget missing: expected false") + } + + target, ok = m.LabelTarget("bug") + if !ok || target != "label-bug" { + t.Errorf("LabelTarget bug: got %q, ok=%v", target, ok) + } + _, ok = m.LabelTarget("empty") + if ok { + t.Error("LabelTarget empty: expected false") + } + _, ok = m.LabelTarget("missing") + if ok { + t.Error("LabelTarget missing: expected false") + } +}