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
This commit is contained in:
toki 2026-05-23 18:47:30 +09:00
parent 881753bc54
commit 401488f299
6 changed files with 504 additions and 10 deletions

View file

@ -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
## 선택 규칙

View file

@ -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 중 하나를 선택한다.

View file

@ -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

View file

@ -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": "<p>Do the thing</p>",
})
}))
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 != "<p>Do the thing</p>" {
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"] != "<p>done</p>" {
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: "<p>done</p>",
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{

View file

@ -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
}

View file

@ -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")
}
}