nomadcode/services/core/internal/adapters/plane/client.go

457 lines
12 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)
_ workitem.Creator = (*Client)(nil)
_ workitem.BodyProjector = (*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"`
State string `json:"state_id"`
StateObj json.RawMessage `json:"state"`
AssigneeIDs []string `json:"assignee_ids"`
Assignees json.RawMessage `json:"assignees"`
}
type CreateIssueInput struct {
WorkspaceSlug string
ProjectID string
Title string
Description string
DescriptionHTML string
}
type AddCommentInput struct {
Ref WorkItemRef
CommentHTML string
Access string
ExternalSource string
ExternalID string
}
type UpdateIssueStatusInput struct {
Ref WorkItemRef
Status string
}
// UpdateWorkItemInput carries an additive PATCH of a Plane work item. Only
// non-empty fields are sent, so a blank Title/DescriptionHTML/Status never
// clears an existing value. Status maps to the Plane `state` field, the same
// field UpdateIssueStatus patches.
type UpdateWorkItemInput struct {
Ref WorkItemRef
Title string
DescriptionHTML string
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.CapabilityCreate: true,
workitem.CapabilityBodyProjection: 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
}
state := item.State
if state == "" && len(item.StateObj) > 0 {
var stateStr string
if err := json.Unmarshal(item.StateObj, &stateStr); err == nil {
state = stateStr
} else {
var stateStruct struct {
ID string `json:"id"`
}
if err := json.Unmarshal(item.StateObj, &stateStruct); err == nil {
state = stateStruct.ID
}
}
}
var assigneeIDs []string
if len(item.AssigneeIDs) > 0 {
assigneeIDs = item.AssigneeIDs
} else if len(item.Assignees) > 0 {
var listStr []string
if err := json.Unmarshal(item.Assignees, &listStr); err == nil && len(listStr) > 0 && listStr[0] != "" {
assigneeIDs = listStr
} else {
var listObj []struct {
ID string `json:"id"`
}
var parsedIDs []string
if err := json.Unmarshal(item.Assignees, &listObj); err == nil {
for _, obj := range listObj {
if obj.ID != "" {
parsedIDs = append(parsedIDs, obj.ID)
}
}
}
if len(parsedIDs) > 0 {
assigneeIDs = parsedIDs
} else {
var listNested []struct {
Assignee struct {
ID string `json:"id"`
} `json:"assignee"`
}
if err := json.Unmarshal(item.Assignees, &listNested); err == nil {
for _, obj := range listNested {
if obj.Assignee.ID != "" {
assigneeIDs = append(assigneeIDs, obj.Assignee.ID)
}
}
}
}
}
}
return workitem.WorkItem{
Ref: ref,
Title: item.Name,
DescriptionText: firstNonEmpty(item.DescriptionStripped, item.Description),
DescriptionHTML: item.DescriptionHTML,
State: state,
AssigneeIDs: assigneeIDs,
}, 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 (c *Client) ProjectBody(ctx context.Context, input workitem.BodyProjection) error {
planeRef, err := planeWorkItemRef(input.Ref)
if err != nil {
return err
}
if strings.TrimSpace(input.Title) == "" && strings.TrimSpace(input.DescriptionHTML) == "" {
return ErrInvalidInput
}
return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
Ref: planeRef,
Title: input.Title,
DescriptionHTML: input.DescriptionHTML,
})
}
func (c *Client) CreateWorkItem(ctx context.Context, input workitem.CreateInput) (workitem.CreateResult, error) {
workspace := strings.TrimSpace(input.Tenant)
project := strings.TrimSpace(input.Project)
item, err := c.CreateIssue(ctx, CreateIssueInput{
WorkspaceSlug: workspace,
ProjectID: project,
Title: input.Title,
Description: input.DescriptionText,
DescriptionHTML: input.DescriptionHTML,
})
if err != nil {
return workitem.CreateResult{}, err
}
return workitem.CreateResult{
Ref: workitem.Ref{
Provider: c.ProviderID(),
Tenant: workspace,
Project: project,
ID: item.ID,
},
}, nil
}
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) (WorkItem, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return WorkItem{}, ErrInvalidInput
}
path := workItemsPath(input.WorkspaceSlug, input.ProjectID)
if path == "" {
return WorkItem{}, ErrInvalidInput
}
body := map[string]any{
"name": title,
}
if descHTML := firstNonEmpty(input.DescriptionHTML, input.Description); descHTML != "" {
body["description_html"] = descHTML
}
var output WorkItem
if err := c.do(ctx, http.MethodPost, path, body, &output); err != nil {
return WorkItem{}, err
}
return output, nil
}
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 {
return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
Ref: input.Ref,
Status: input.Status,
})
}
// UpdateWorkItem PATCHes the work item with only the non-empty fields in input.
// name, description_html, and state are sent independently so a body/title
// update never disturbs the state, and a status move never disturbs the body.
// An input with no non-empty field is an invalid no-op patch.
func (c *Client) UpdateWorkItem(ctx context.Context, input UpdateWorkItemInput) error {
body := map[string]string{}
if title := strings.TrimSpace(input.Title); title != "" {
body["name"] = title
}
if descHTML := strings.TrimSpace(input.DescriptionHTML); descHTML != "" {
body["description_html"] = descHTML
}
if status := strings.TrimSpace(input.Status); status != "" {
body["state"] = status
}
if len(body) == 0 {
return ErrInvalidInput
}
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 {
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("%w: plane API request failed: status %d: %s", workitem.ErrNotFound, resp.StatusCode, strings.TrimSpace(string(raw)))
}
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 workItemsPath(workspaceSlug, projectID string) string {
workspaceSlug = strings.TrimSpace(workspaceSlug)
projectID = strings.TrimSpace(projectID)
if workspaceSlug == "" || projectID == "" {
return ""
}
return fmt.Sprintf(
"/api/v1/workspaces/%s/projects/%s/work-items/",
url.PathEscape(workspaceSlug),
url.PathEscape(projectID),
)
}
func (c *Client) log(ctx context.Context, message string, args ...any) {
_ = ctx
if c.logger != nil {
c.logger.Info(message, args...)
}
}