<!-- task=work_item_provider_interface plan=0 tag=REFACTOR -->

# Work Item Provider Adapter Interface

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것은 필수다. 구현 후 검증 명령을 실제로 실행하고, 실제 출력과 주요 설계 결정, 계획 대비 변경 사항을 review stub에 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태로 보고한다. 최종 판정, `.log` 아카이브, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.

## 배경

현재 core는 Plane work item을 직접 받아 내부 task로 변환하는 경로를 갖고 있지만, Jira까지 확장할 provider 추상화 경계가 없다. HTTP 레이어가 Plane DTO를 직접 알고 있어 이후 webhook, polling, scheduler trigger를 Plane 중심으로 끌고 갈 위험이 있다. 이번 항목은 실제 trigger 구현 전에 work item provider 인터페이스와 DTO를 먼저 고정해 Plane/Jira 어댑터가 같은 core 계약으로 붙을 수 있게 한다.

## 분석 결과

### 읽은 파일

- `agent-ops/skills/common/plan/SKILL.md`
- `services/core/go.mod`
- `services/core/cmd/server/main.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/internal/adapters/plane/client.go`
- `services/core/internal/adapters/plane/client_test.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/http/router.go`
- `services/core/internal/workflow/model.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/workflow/service_test.go`

### 테스트 커버리지 공백

- provider-neutral DTO/interface/mapping 타입은 아직 존재하지 않으므로 기존 테스트가 없다. `internal/workitem` 테스트를 새로 작성해 ref 정규화와 adapter mapping lookup을 검증한다.
- Plane raw API client는 `client_test.go`에서 endpoint/header/body를 검증하지만 provider-neutral facet method mapping은 검증하지 않는다. Plane adapter 테스트를 추가해 `workitem.Ref`가 Plane `workspace/project/work_item` 경로로 변환되는지, comment/status projection이 기존 API 호출로 연결되는지 검증한다.
- 기존 HTTP Plane task 변환 테스트는 유지 대상이다. 이번 항목은 route/call-site rollout을 하지 않으므로 HTTP 테스트는 변경하지 않고 최종 검증으로 회귀만 확인한다.
- workflow external ref 정규화 테스트는 이미 provider-neutral 저장 경계를 검증한다. 이번 항목은 workflow 저장 계약을 바꾸지 않으므로 회귀만 확인한다.

### 심볼 참조

- renamed/removed symbols: none.
- 새 인터페이스 메서드는 기존 `plane.Client.GetWorkItem`, `plane.Client.AddComment`, `plane.Client.UpdateIssueStatus`와 이름 충돌이 나지 않도록 `FetchWorkItem`, `AppendComment`, `ProjectStatus`, `ProjectLabels` 계열로 둔다.

### 분할 판단

split decision policy를 먼저 평가했다. 이번 작업은 foundation API와 Plane conformance가 함께 있으나 broad call-site rollout을 포함하지 않는다. 새 provider-neutral 패키지만 만들면 Plane/Jira 구현 가능성을 컴파일/테스트로 검증할 수 없어 설계가 문서 수준에 머물고, Plane 전체 endpoint 리팩터링을 함께 넣으면 다음 체크리스트 항목까지 침범한다. 따라서 단일 plan으로 유지하고, 범위는 `internal/workitem` 계약과 Plane adapter의 좁은 facet 구현/테스트로 제한한다.

### 범위 결정 근거

- `services/core/internal/http/handlers.go`의 `/integrations/plane/tasks` 로직은 이번 계획에서 refactor하지 않는다. 현재 Plane task 생성 경로를 깨지 않고 다음 체크리스트의 Plane endpoint refactor에서 전환한다.
- webhook-first trigger, polling fallback, idempotency, scheduler enqueue 조건은 이번 계획에서 구현하지 않는다. provider contract가 먼저 있어야 trigger 입력이 흔들리지 않는다.
- Jira adapter는 구현하지 않는다. 대신 Jira가 구현 가능한 `tenant/project/id`, canonical/provider projection target, optional facet 구조를 core 계약에 반영한다.
- DB migration과 workflow storage 변경은 하지 않는다. `workflow.ExternalRefInput`과 DB external columns가 이미 provider-neutral 저장 경계 역할을 한다.

### 빌드 등급

- build lane: `cloud-G06`
- review lane: `cloud-G06`
- 근거: cross-provider adapter contract와 projection schema 성격이 있어 설계 판단 리스크가 있고, 새 내부 API와 Plane conformance 테스트가 함께 필요하다.

## 구현 체크리스트

- [ ] `services/core/internal/workitem` 패키지에 provider-neutral DTO, facet interface, projection mapping 타입을 추가하고 단위 테스트를 작성한다.
- [ ] Plane adapter에 provider-neutral facet method와 변환 helper를 추가하고 기존 Plane-specific API method를 유지한 채 conformance 테스트를 작성한다.
- [ ] `gofmt`, provider-neutral import boundary 검색, 관련 Go 테스트를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 구현 항목

### [REFACTOR-1] Provider-neutral work item contract를 추가한다

#### 문제

HTTP handler가 Plane DTO를 직접 참조한다. `services/core/internal/http/handlers.go:21-23`의 `PlaneWorkItemClient`는 `plane.WorkItemRef`와 `plane.WorkItem`을 노출하고, `services/core/internal/http/handlers.go:176-188`의 request conversion도 Plane 전용 ref를 만든다. 반면 workflow 저장 경계는 `services/core/internal/workflow/model.go:23-28`에서 provider/id/url/metadata만 다루므로 work item lookup/comment/status/label projection을 표현할 공통 계약이 비어 있다.

Before (`services/core/internal/http/handlers.go:21-23`):

```go
type PlaneWorkItemClient interface {
	GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error)
}
```

After (`services/core/internal/workitem/provider.go`):

```go
package workitem

import (
	"context"
	"encoding/json"
)

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"`
}
```

#### 해결 방법

- `services/core/internal/workitem/provider.go`를 새로 만들고 provider-neutral DTO, optional facet interface, capabilities, projection 타입을 정의한다.
- `NormalizeRef(ref Ref) (Ref, error)`를 추가해 `Provider`와 `ID`는 필수로 정규화하고 `Tenant`, `Project`, `URL`은 trim한다. `Metadata`는 빈 값이면 `{}`로 채우고 invalid JSON이면 package-level `ErrInvalidRef`를 반환한다.
- `Mapping` 타입을 추가해 canonical state/label과 provider target id를 분리한다.

```go
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 != ""
}
```

- `go.mod`에는 새 dependency를 추가하지 않는다. 표준 라이브러리만 사용한다.

#### 수정 파일 및 체크리스트

- [ ] `services/core/internal/workitem/provider.go`를 추가한다.
- [ ] `Provider`, `Reader`, `Commenter`, `StatusProjector`, `LabelProjector`를 optional facet으로 둔다.
- [ ] `StatusProjection`과 `LabelProjection`에 canonical 값과 provider target 값을 함께 둬 adapter config mapping 결과를 전달할 수 있게 한다.
- [ ] `NormalizeRef`와 `Mapping` helper를 표준 라이브러리만으로 구현한다.
- [ ] `services/core/go.mod`에 dependency 변화가 없음을 확인한다.

#### 테스트 작성

- 작성: `services/core/internal/workitem/provider_test.go`
- `TestNormalizeRefTrimsAndDefaultsMetadata`: provider/id/tenant/project/url trim, metadata 기본 `{}` 확인.
- `TestNormalizeRefRequiresProviderAndID`: provider 또는 id가 비면 error 확인.
- `TestNormalizeRefRejectsInvalidMetadata`: invalid JSON metadata error 확인.
- `TestMappingReturnsTrimmedProviderTargets`: canonical state/label이 provider target으로 매핑되고 빈 target은 false가 되는지 확인.

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/workitem
```

기대 결과: 새 `internal/workitem` 테스트가 fresh run으로 통과한다.

### [REFACTOR-2] Plane adapter를 provider-neutral facet에 맞춘다

#### 문제

Plane adapter는 현재 Plane 전용 input/output만 노출한다. `services/core/internal/adapters/plane/client.go:33-63`의 `WorkItemRef`, `WorkItem`, `AddCommentInput`, `UpdateIssueStatusInput`은 Jira adapter가 공유할 수 없고, `services/core/internal/adapters/plane/client.go:81-110`의 method 이름은 기존 타입과 묶여 있어 core provider interface로 바로 쓸 수 없다.

Before (`services/core/internal/adapters/plane/client.go:81-110`):

```go
func (c *Client) GetWorkItem(ctx context.Context, ref WorkItemRef) (WorkItem, error) {
	var output WorkItem
	if err := c.do(ctx, http.MethodGet, workItemPath(ref), nil, &output); err != nil {
		return WorkItem{}, err
	}
	return output, nil
}

func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error {
	body := map[string]any{
		"comment_html": input.CommentHTML,
	}
	// ...
	return c.do(ctx, http.MethodPost, workItemPath(input.Ref)+"/comments/", body, nil)
}

func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error {
	body := map[string]string{
		"state": input.Status,
	}
	return c.do(ctx, http.MethodPatch, workItemPath(input.Ref)+"/", body, nil)
}
```

After (`services/core/internal/adapters/plane/client.go`):

```go
import "github.com/nomadcode/nomadcode-core/internal/workitem"

var _ workitem.Provider = (*Client)(nil)
var _ workitem.Reader = (*Client)(nil)
var _ workitem.Commenter = (*Client)(nil)
var _ workitem.StatusProjector = (*Client)(nil)

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,
	})
}
```

#### 해결 방법

- 기존 Plane-specific method와 DTO는 유지한다. 현재 HTTP route가 계속 사용하므로 rename/remove 하지 않는다.
- `planeWorkItemRef(workitem.Ref)` helper를 추가해 `Tenant -> WorkspaceSlug`, `Project -> ProjectID`, `ID -> WorkItemID`로 매핑한다.
- Plane adapter가 `Provider`, `Reader`, `Commenter`, `StatusProjector`를 구현한다고 compile-time assertion을 둔다. `LabelProjector`는 현재 구현하지 않고 capability false로 둔다.
- label projection endpoint 구현은 다음 항목으로 미루되, core contract에는 `LabelProjector` facet을 남겨 Jira/Plane 양쪽 확장을 막지 않는다.

#### 수정 파일 및 체크리스트

- [ ] `services/core/internal/adapters/plane/client.go`에 `internal/workitem` import를 추가한다.
- [ ] `ProviderID`, `Capabilities`, `FetchWorkItem`, `AppendComment`, `ProjectStatus` method를 추가한다.
- [ ] `planeWorkItemRef` helper가 missing tenant/project/id를 `ErrInvalidInput`으로 반환하게 한다.
- [ ] 기존 `GetWorkItem`, `AddComment`, `UpdateIssueStatus` method signature와 동작을 유지한다.
- [ ] `LabelProjector` compile assertion은 추가하지 않는다.

#### 테스트 작성

- 작성/수정: `services/core/internal/adapters/plane/client_test.go`
- `TestClientImplementsWorkItemFacets`: capabilities와 compile-time assertion 범위를 검증한다.
- `TestFetchWorkItemMapsNeutralRefToPlaneEndpoint`: `workitem.Ref{Provider:"plane", Tenant:"general", Project:"project-1", ID:"work-1"}`가 기존 Plane endpoint로 조회되고 neutral `WorkItem`으로 매핑되는지 확인한다.
- `TestAppendCommentMapsNeutralComment`: neutral comment body/external source/id/visibility가 Plane comment body로 전달되는지 확인한다.
- `TestProjectStatusRequiresProviderState`: provider state가 비면 error, 있으면 Plane state patch body가 되는지 확인한다.

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/adapters/plane
```

기대 결과: Plane raw API tests와 provider-neutral facet tests가 모두 fresh run으로 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/workitem/provider.go` | REFACTOR-1 |
| `services/core/internal/workitem/provider_test.go` | REFACTOR-1 |
| `services/core/internal/adapters/plane/client.go` | REFACTOR-2 |
| `services/core/internal/adapters/plane/client_test.go` | REFACTOR-2 |
| `services/core/go.mod` | REFACTOR-1 dependency 변화 없음 확인 |

## 리뷰어를 위한 체크포인트

- `internal/workitem`이 provider adapter 패키지를 import하지 않는지 확인한다.
- Plane adapter의 기존 Plane-specific method signature가 유지됐는지 확인한다.
- Plane neutral facet methods가 `Tenant/Project/ID`를 Plane `WorkspaceSlug/ProjectID/WorkItemID`로만 변환하고 HTTP handler까지 전환하지 않았는지 확인한다.
- `LabelProjector`가 core contract에는 있지만 Plane capability는 false로 남아 있는지 확인한다.
- mapping type이 canonical 상태/라벨과 provider target id를 분리하는지 확인한다.

## 최종 검증

```bash
cd services/core && gofmt -w internal/workitem/*.go internal/adapters/plane/client.go internal/adapters/plane/client_test.go
```

기대 결과: 출력 없음. 필요한 파일이 gofmt 적용된다.

```bash
cd services/core && ! rg --sort path -n '"github.com/nomadcode/nomadcode-core/internal/adapters/plane"|plane\\.' internal/workitem internal/workflow
```

기대 결과: 출력 없음. `internal/workitem`과 `internal/workflow`가 Plane adapter를 직접 참조하지 않는다.

```bash
cd services/core && go test -count=1 ./internal/workitem ./internal/adapters/plane ./internal/http ./internal/workflow
```

기대 결과: 관련 패키지 테스트가 모두 fresh run으로 통과한다. cached output은 허용하지 않는다.

```bash
git diff --check
```

기대 결과: whitespace error가 없다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
