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...) } }