nomadcode/services/core/internal/adapters/plane/client.go
toki 401488f299 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
2026-05-23 18:47:30 +09:00

285 lines
6.9 KiB
Go

package plane
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"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 (
ErrNotImplemented = errors.New("plane adapter method is not implemented")
ErrMissingConfig = errors.New("plane adapter requires base URL and token")
ErrInvalidInput = errors.New("invalid plane adapter input")
)
type Config struct {
BaseURL string
Token string
}
type Client struct {
cfg Config
http *http.Client
logger *slog.Logger
}
type WorkItemRef struct {
WorkspaceSlug string
ProjectID string
WorkItemID string
}
type WorkItem struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DescriptionHTML string `json:"description_html"`
DescriptionStripped string `json:"description_stripped"`
}
type CreateIssueInput struct {
Title string
Description string
}
type AddCommentInput struct {
Ref WorkItemRef
CommentHTML string
Access string
ExternalSource string
ExternalID string
}
type UpdateIssueStatusInput struct {
Ref WorkItemRef
Status string
}
func NewClient(cfg Config, logger *slog.Logger) *Client {
return &Client{cfg: cfg, http: http.DefaultClient, logger: logger}
}
func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.Logger) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
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
}
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,
}
if input.Access != "" {
body["access"] = input.Access
}
if input.ExternalSource != "" {
body["external_source"] = input.ExternalSource
}
if input.ExternalID != "" {
body["external_id"] = input.ExternalID
}
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)
}
func (c *Client) do(ctx context.Context, method, path string, input any, output any) error {
baseURL, token, err := c.config()
if err != nil {
return err
}
if strings.TrimSpace(path) == "" {
return ErrInvalidInput
}
endpoint := baseURL + path
var body io.Reader
if input != nil {
raw, err := json.Marshal(input)
if err != nil {
return err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", token)
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("plane API request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
if output == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, output); err != nil {
return fmt.Errorf("decode plane API response: %w", err)
}
return nil
}
func (c *Client) config() (string, string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
token := strings.TrimSpace(c.cfg.Token)
if baseURL == "" || token == "" {
return "", "", ErrMissingConfig
}
if _, err := url.ParseRequestURI(baseURL); err != nil {
return "", "", err
}
return baseURL, token, nil
}
func workItemPath(ref WorkItemRef) string {
workspaceSlug := strings.TrimSpace(ref.WorkspaceSlug)
projectID := strings.TrimSpace(ref.ProjectID)
workItemID := strings.TrimSpace(ref.WorkItemID)
if workspaceSlug == "" || projectID == "" || workItemID == "" {
return ""
}
return fmt.Sprintf(
"/api/v1/workspaces/%s/projects/%s/work-items/%s",
url.PathEscape(workspaceSlug),
url.PathEscape(projectID),
url.PathEscape(workItemID),
)
}
func (c *Client) log(ctx context.Context, message string, args ...any) {
_ = ctx
if c.logger != nil {
c.logger.Info(message, args...)
}
}