nomadcode/services/core/internal/workitem/provider.go
toki 8803d29554 feat: work item creation sync milestone implementation
- Add Plane API client integration for work item creation
- Update HTTP handlers for work item sync endpoints
- Implement workitem provider and pipeline service updates
- Add test cases for Plane client, handlers, and pipeline
- Update milestone documentation
- Archive task artifacts from 09_creation_trigger_gate and 10+09_plane_draft_authoring
2026-06-14 06:38:50 +09:00

251 lines
6.4 KiB
Go

package workitem
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/nomadcode/nomadcode-core/internal/workflow"
)
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"
CapabilityCreate Capability = "create"
CapabilityBodyProjection Capability = "body_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 Creator interface {
CreateWorkItem(ctx context.Context, input CreateInput) (CreateResult, error)
}
// BodyProjector updates an existing work item's title and/or body. It is an
// additive projection capability separate from status projection so a body
// rewrite and a status move stay independent steps.
type BodyProjector interface {
ProjectBody(ctx context.Context, input BodyProjection) 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"`
}
func (w WorkItem) HasAssignee(id string) bool {
target := strings.TrimSpace(id)
if target == "" {
return false
}
for _, a := range w.AssigneeIDs {
if strings.TrimSpace(a) == target {
return true
}
}
return false
}
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 BodyProjection struct {
Ref Ref `json:"ref"`
Title string `json:"title,omitempty"`
DescriptionHTML string `json:"description_html,omitempty"`
}
type CreateInput struct {
Provider ProviderID `json:"provider"`
Tenant string `json:"tenant"`
Project string `json:"project"`
Title string `json:"title"`
DescriptionText string `json:"description_text,omitempty"`
DescriptionHTML string `json:"description_html,omitempty"`
}
type CreateResult struct {
Ref Ref `json:"ref"`
}
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 != ""
}
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) {
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
}