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:
parent
881753bc54
commit
401488f299
6 changed files with 504 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
## 선택 규칙
|
||||
|
||||
|
|
|
|||
|
|
@ -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 중 하나를 선택한다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
141
services/core/internal/workitem/provider.go
Normal file
141
services/core/internal/workitem/provider.go
Normal 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
|
||||
}
|
||||
120
services/core/internal/workitem/provider_test.go
Normal file
120
services/core/internal/workitem/provider_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue