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"` } 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 } 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 (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 { 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...) } }